diff options
64 files changed, 2588 insertions, 2002 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 32746f27b..d87786008 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -316,21 +316,14 @@ export function readyComposeSuggestionsAccounts(token, accounts) { export function selectComposeSuggestion(position, token, suggestion) { return (dispatch, getState) => { - let completion, startPosition; - - if (typeof suggestion === 'object' && suggestion.id) { - completion = suggestion.native || suggestion.colons; - startPosition = position - 1; - - dispatch(useEmoji(suggestion)); - } else { - completion = getState().getIn(['accounts', suggestion, 'acct']); - startPosition = position; - } + const completion = typeof suggestion === 'object' && suggestion.id ? ( + dispatch(useEmoji(suggestion)), + suggestion.native || suggestion.colons + ) : '@' + getState().getIn(['accounts', suggestion, 'acct']); dispatch({ type: COMPOSE_SUGGESTION_SELECT, - position: startPosition, + position, token, completion, }); diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js index a1f075491..ced18b348 100644 --- a/app/javascript/flavours/glitch/components/account.js +++ b/app/javascript/flavours/glitch/components/account.js @@ -30,6 +30,7 @@ export default class Account extends ImmutablePureComponent { onMuteNotifications: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, hidden: PropTypes.bool, + small: PropTypes.bool, }; handleFollow = () => { @@ -53,7 +54,12 @@ export default class Account extends ImmutablePureComponent { } render () { - const { account, intl, hidden } = this.props; + const { + account, + hidden, + intl, + small, + } = this.props; if (!account) { return <div />; @@ -70,7 +76,7 @@ export default class Account extends ImmutablePureComponent { let buttons; - if (account.get('id') !== me && account.get('relationship', null) !== null) { + if (account.get('id') !== me && !small && account.get('relationship', null) !== null) { const following = account.getIn(['relationship', 'following']); const requested = account.getIn(['relationship', 'requested']); const blocking = account.getIn(['relationship', 'blocking']); @@ -98,17 +104,23 @@ export default class Account extends ImmutablePureComponent { } } - return ( + return small ? ( + <div className='account small'> + <div className='account__avatar-wrapper'><Avatar account={account} size={18} /></div> + <DisplayName account={account} /> + </div> + ) : ( <div className='account'> <div className='account__wrapper'> <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> <DisplayName account={account} /> </Permalink> - - <div className='account__relationship'> - {buttons} - </div> + {buttons ? + <div className='account__relationship'> + {buttons} + </div> + : null} </div> </div> ); diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js deleted file mode 100644 index 79e113d9c..000000000 --- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; - -const assetHost = process.env.CDN_HOST || ''; - -export default class AutosuggestEmoji extends React.PureComponent { - - static propTypes = { - emoji: PropTypes.object.isRequired, - }; - - render () { - const { emoji } = this.props; - let url; - - if (emoji.custom) { - url = emoji.imageUrl; - } else { - const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; - - if (!mapping) { - return null; - } - - url = `${assetHost}/emoji/${mapping.filename}.svg`; - } - - return ( - <div className='autosuggest-emoji'> - <img - className='emojione' - src={url} - alt={emoji.native || emoji.colons} - /> - - {emoji.colons} - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js deleted file mode 100644 index a29b2c9c5..000000000 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js +++ /dev/null @@ -1,223 +0,0 @@ -import React from 'react'; -import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; -import AutosuggestEmoji from './autosuggest_emoji'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PropTypes from 'prop-types'; -import { isRtl } from 'flavours/glitch/util/rtl'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import Textarea from 'react-textarea-autosize'; -import classNames from 'classnames'; - -const textAtCursorMatchesToken = (str, caretPosition) => { - let word; - - let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/); - let right = str.slice(caretPosition).search(/[\s\u200B]/); - - if (right < 0) { - word = str.slice(left); - } else { - word = str.slice(left, right + caretPosition); - } - - if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { - return [null, null]; - } - - word = word.trim().toLowerCase(); - - if (word.length > 0) { - return [left + 1, word]; - } else { - return [null, null]; - } -}; - -export default class AutosuggestTextarea extends ImmutablePureComponent { - - static propTypes = { - value: PropTypes.string, - suggestions: ImmutablePropTypes.list, - disabled: PropTypes.bool, - placeholder: PropTypes.string, - onSuggestionSelected: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - onPaste: PropTypes.func.isRequired, - autoFocus: PropTypes.bool, - }; - - static defaultProps = { - autoFocus: true, - }; - - state = { - suggestionsHidden: false, - selectedSuggestion: 0, - lastToken: null, - tokenStart: 0, - }; - - onChange = (e) => { - const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); - - if (token !== null && this.state.lastToken !== token) { - this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); - this.props.onSuggestionsFetchRequested(token); - } else if (token === null) { - this.setState({ lastToken: null }); - this.props.onSuggestionsClearRequested(); - } - - this.props.onChange(e); - } - - onKeyDown = (e) => { - const { suggestions, disabled } = this.props; - const { selectedSuggestion, suggestionsHidden } = this.state; - - if (disabled) { - e.preventDefault(); - return; - } - - switch(e.key) { - case 'Escape': - if (!suggestionsHidden) { - e.preventDefault(); - this.setState({ suggestionsHidden: true }); - } - - break; - case 'ArrowDown': - if (suggestions.size > 0 && !suggestionsHidden) { - e.preventDefault(); - this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); - } - - break; - case 'ArrowUp': - if (suggestions.size > 0 && !suggestionsHidden) { - e.preventDefault(); - this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); - } - - break; - case 'Enter': - case 'Tab': - // Select suggestion - if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { - e.preventDefault(); - e.stopPropagation(); - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); - } - - break; - } - - if (e.defaultPrevented || !this.props.onKeyDown) { - return; - } - - this.props.onKeyDown(e); - } - - onKeyUp = e => { - if (e.key === 'Escape' && this.state.suggestionsHidden) { - document.querySelector('.ui').parentElement.focus(); - } - - if (this.props.onKeyUp) { - this.props.onKeyUp(e); - } - } - - onBlur = () => { - this.setState({ suggestionsHidden: true }); - } - - onSuggestionClick = (e) => { - const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); - e.preventDefault(); - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); - this.textarea.focus(); - } - - componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) { - this.setState({ suggestionsHidden: false }); - } - } - - setTextarea = (c) => { - this.textarea = c; - } - - onPaste = (e) => { - if (e.clipboardData && e.clipboardData.files.length === 1) { - this.props.onPaste(e.clipboardData.files); - e.preventDefault(); - } - } - - renderSuggestion = (suggestion, i) => { - const { selectedSuggestion } = this.state; - let inner, key; - - if (typeof suggestion === 'object') { - inner = <AutosuggestEmoji emoji={suggestion} />; - key = suggestion.id; - } else { - inner = <AutosuggestAccountContainer id={suggestion} />; - key = suggestion; - } - - return ( - <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> - {inner} - </div> - ); - } - - render () { - const { value, suggestions, disabled, placeholder, autoFocus } = this.props; - const { suggestionsHidden } = this.state; - const style = { direction: 'ltr' }; - - if (isRtl(value)) { - style.direction = 'rtl'; - } - - return ( - <div className='autosuggest-textarea'> - <label> - <span style={{ display: 'none' }}>{placeholder}</span> - - <Textarea - inputRef={this.setTextarea} - className='autosuggest-textarea__textarea' - disabled={disabled} - placeholder={placeholder} - autoFocus={autoFocus} - value={value} - onChange={this.onChange} - onKeyDown={this.onKeyDown} - onKeyUp={this.onKeyUp} - onBlur={this.onBlur} - onPaste={this.onPaste} - style={style} - aria-autocomplete='list' - /> - </label> - - <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> - {suggestions.map(this.renderSuggestion)} - </div> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js new file mode 100644 index 000000000..8f55a0115 --- /dev/null +++ b/app/javascript/flavours/glitch/components/icon.js @@ -0,0 +1,26 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// This just renders a FontAwesome icon. +export default function Icon ({ + className, + fullwidth, + icon, +}) { + const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className); + return icon ? ( + <span + aria-hidden='true' + className={computedClass} + /> + ) : null; +} + +// Props. +Icon.propTypes = { + className: PropTypes.string, + fullwidth: PropTypes.bool, + icon: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js index 9c8ffab1f..9c8ffab1f 100644 --- a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js +++ b/app/javascript/flavours/glitch/components/text_icon_button.js diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options.js deleted file mode 100644 index 045bad2e5..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js +++ /dev/null @@ -1,62 +0,0 @@ -// Package imports. -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { injectIntl, defineMessages } from 'react-intl'; - -// Our imports. -import ComposeAdvancedOptionsToggle from './advanced_options_toggle'; -import ComposeDropdown from './dropdown'; - -const messages = defineMessages({ - local_only_short : - { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' }, - local_only_long : - { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' }, - advanced_options_icon_title : - { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' }, -}); - -@injectIntl -export default class ComposeAdvancedOptions extends React.PureComponent { - - static propTypes = { - values : ImmutablePropTypes.contains({ - do_not_federate : PropTypes.bool.isRequired, - }).isRequired, - onChange : PropTypes.func.isRequired, - intl : PropTypes.object.isRequired, - }; - - render () { - const { intl, values } = this.props; - const options = [ - { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' }, - ]; - const anyEnabled = values.some((enabled) => enabled); - - const optionElems = options.map((option) => { - return ( - <ComposeAdvancedOptionsToggle - onChange={this.props.onChange} - active={values.get(option.name)} - key={option.name} - name={option.name} - shortText={intl.formatMessage(option.shortText)} - longText={intl.formatMessage(option.longText)} - /> - ); - }); - - return ( - <ComposeDropdown - title={intl.formatMessage(messages.advanced_options_icon_title)} - icon='home' - highlight={anyEnabled} - > - {optionElems} - </ComposeDropdown> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js deleted file mode 100644 index 98b3b6a44..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js +++ /dev/null @@ -1,35 +0,0 @@ -// Package imports. -import React from 'react'; -import PropTypes from 'prop-types'; -import Toggle from 'react-toggle'; - -export default class ComposeAdvancedOptionsToggle extends React.PureComponent { - - static propTypes = { - onChange: PropTypes.func.isRequired, - active: PropTypes.bool.isRequired, - name: PropTypes.string.isRequired, - shortText: PropTypes.string.isRequired, - longText: PropTypes.string.isRequired, - } - - onToggle = () => { - this.props.onChange(this.props.name); - } - - render() { - const { active, shortText, longText } = this.props; - return ( - <div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}> - <div className='advanced-options-dropdown__option__toggle'> - <Toggle checked={active} onChange={this.onToggle} /> - </div> - <div className='advanced-options-dropdown__option__content'> - <strong>{shortText}</strong> - {longText} - </div> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/attach_options.js b/app/javascript/flavours/glitch/features/compose/components/attach_options.js deleted file mode 100644 index 6c7a1f55f..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/attach_options.js +++ /dev/null @@ -1,131 +0,0 @@ -// Package imports // -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { injectIntl, defineMessages } from 'react-intl'; - -// Our imports // -import ComposeDropdown from './dropdown'; -import { uploadCompose } from 'flavours/glitch/actions/compose'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { openModal } from 'flavours/glitch/actions/modal'; - -const messages = defineMessages({ - upload : - { id: 'compose.attach.upload', defaultMessage: 'Upload a file' }, - doodle : - { id: 'compose.attach.doodle', defaultMessage: 'Draw something' }, - attach : - { id: 'compose.attach', defaultMessage: 'Attach...' }, -}); - -const mapStateToProps = state => ({ - // This horrible expression is copied from vanilla upload_button_container - disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), - resetFileKey: state.getIn(['compose', 'resetFileKey']), - acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), -}); - -const mapDispatchToProps = dispatch => ({ - onSelectFile (files) { - dispatch(uploadCompose(files)); - }, - onOpenDoodle () { - dispatch(openModal('DOODLE', { noEsc: true })); - }, -}); - -@injectIntl -@connect(mapStateToProps, mapDispatchToProps) -export default class ComposeAttachOptions extends ImmutablePureComponent { - - static propTypes = { - intl : PropTypes.object.isRequired, - resetFileKey: PropTypes.number, - acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, - disabled: PropTypes.bool, - onSelectFile: PropTypes.func.isRequired, - onOpenDoodle: PropTypes.func.isRequired, - }; - - handleItemClick = bt => { - if (bt === 'upload') { - this.fileElement.click(); - } - - if (bt === 'doodle') { - this.props.onOpenDoodle(); - } - - this.dropdown.setState({ open: false }); - }; - - handleFileChange = (e) => { - if (e.target.files.length > 0) { - this.props.onSelectFile(e.target.files); - } - } - - setFileRef = (c) => { - this.fileElement = c; - } - - setDropdownRef = (c) => { - this.dropdown = c; - } - - render () { - const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; - - const options = [ - { icon: 'cloud-upload', text: messages.upload, name: 'upload' }, - { icon: 'paint-brush', text: messages.doodle, name: 'doodle' }, - ]; - - const optionElems = options.map((item) => { - const hdl = () => this.handleItemClick(item.name); - return ( - <div - role='button' - tabIndex='0' - key={item.name} - onClick={hdl} - className='privacy-dropdown__option' - > - <div className='privacy-dropdown__option__icon'> - <i className={`fa fa-fw fa-${item.icon}`} /> - </div> - - <div className='privacy-dropdown__option__content'> - <strong>{intl.formatMessage(item.text)}</strong> - </div> - </div> - ); - }); - - return ( - <div> - <ComposeDropdown - title={intl.formatMessage(messages.attach)} - icon='paperclip' - disabled={disabled} - ref={this.setDropdownRef} - > - {optionElems} - </ComposeDropdown> - <input - key={resetFileKey} - ref={this.setFileRef} - type='file' - multiple={false} - accept={acceptContentTypes.toArray().join(',')} - onChange={this.handleFileChange} - disabled={disabled} - style={{ display: 'none' }} - /> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js deleted file mode 100644 index 3d474af30..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import Avatar from 'flavours/glitch/components/avatar'; -import DisplayName from 'flavours/glitch/components/display_name'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; - -export default class AutosuggestAccount extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.map.isRequired, - }; - - render () { - const { account } = this.props; - - return ( - <div className='autosuggest-account'> - <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div> - <DisplayName account={account} /> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js deleted file mode 100644 index 0ecfc9141..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/character_counter.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { length } from 'stringz'; - -export default class CharacterCounter extends React.PureComponent { - - 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>; - } - - render () { - const diff = this.props.max - length(this.props.text); - return this.checkRemainingText(diff); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js deleted file mode 100644 index 67ce935f4..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ /dev/null @@ -1,286 +0,0 @@ -import React from 'react'; -import CharacterCounter from './character_counter'; -import Button from 'flavours/glitch/components/button'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PropTypes from 'prop-types'; -import ReplyIndicatorContainer from '../containers/reply_indicator_container'; -import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea'; -import { defineMessages, injectIntl } from 'react-intl'; -import Collapsable from 'flavours/glitch/components/collapsable'; -import SpoilerButtonContainer from '../containers/spoiler_button_container'; -import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; -import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container'; -import SensitiveButtonContainer from '../containers/sensitive_button_container'; -import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; -import UploadFormContainer from '../containers/upload_form_container'; -import WarningContainer from '../containers/warning_container'; -import { isMobile } from 'flavours/glitch/util/is_mobile'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { length } from 'stringz'; -import { countableText } from 'flavours/glitch/util/counter'; -import ComposeAttachOptions from './attach_options'; -import initialState from 'flavours/glitch/util/initial_state'; - -const maxChars = initialState.max_toot_chars; - -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: 'Toot' }, - publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, -}); - -@injectIntl -export default class ComposeForm extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - text: PropTypes.string.isRequired, - suggestion_token: PropTypes.string, - suggestions: ImmutablePropTypes.list, - spoiler: PropTypes.bool, - privacy: PropTypes.string, - advanced_options: ImmutablePropTypes.contains({ - do_not_federate: PropTypes.bool, - }), - spoiler_text: PropTypes.string, - focusDate: PropTypes.instanceOf(Date), - preselectDate: PropTypes.instanceOf(Date), - is_submitting: PropTypes.bool, - is_uploading: PropTypes.bool, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onClearSuggestions: PropTypes.func.isRequired, - onFetchSuggestions: PropTypes.func.isRequired, - onPrivacyChange: PropTypes.func.isRequired, - onSuggestionSelected: PropTypes.func.isRequired, - onChangeSpoilerText: PropTypes.func.isRequired, - onPaste: PropTypes.func.isRequired, - onPickEmoji: PropTypes.func.isRequired, - showSearch: PropTypes.bool, - settings : ImmutablePropTypes.map.isRequired, - }; - - static defaultProps = { - showSearch: false, - }; - - handleChange = (e) => { - this.props.onChange(e.target.value); - } - - handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); - } - } - - handleSubmit2 = () => { - this.props.onPrivacyChange(this.props.settings.get('side_arm')); - this.handleSubmit(); - } - - handleSubmit = () => { - if (this.props.text !== this.autosuggestTextarea.textarea.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.onSubmit(); - } - - onSuggestionsClearRequested = () => { - this.props.onClearSuggestions(); - } - - onSuggestionsFetchRequested = (token) => { - this.props.onFetchSuggestions(token); - } - - onSuggestionSelected = (tokenStart, token, value) => { - this._restoreCaret = null; - this.props.onSuggestionSelected(tokenStart, token, value); - } - - handleChangeSpoilerText = (e) => { - this.props.onChangeSpoilerText(e.target.value); - } - - componentWillReceiveProps (nextProps) { - // If this is the update where we've finished uploading, - // save the last caret position so we can restore it below! - if (!nextProps.is_uploading && this.props.is_uploading) { - this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart; - } - } - - componentDidUpdate (prevProps) { - // This statement does several things: - // - If we're beginning a reply, and, - // - Replying to zero or one users, places the cursor at the end of the textbox. - // - Replying to more than one user, selects any usernames past the first; - // this provides a convenient shortcut to drop everyone else from the conversation. - // - If we've just finished uploading an image, and have a saved caret position, - // restores the cursor to that position after the text changes! - if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) { - let selectionEnd, selectionStart; - - if (this.props.preselectDate !== prevProps.preselectDate) { - selectionEnd = this.props.text.length; - selectionStart = this.props.text.search(/\s/) + 1; - } else if (typeof this._restoreCaret === 'number') { - selectionStart = this._restoreCaret; - selectionEnd = this._restoreCaret; - } else { - selectionEnd = this.props.text.length; - selectionStart = selectionEnd; - } - - this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); - this.autosuggestTextarea.textarea.focus(); - } else if(prevProps.is_submitting && !this.props.is_submitting) { - this.autosuggestTextarea.textarea.focus(); - } - } - - setAutosuggestTextarea = (c) => { - this.autosuggestTextarea = c; - } - - handleEmojiPick = (data) => { - const position = this.autosuggestTextarea.textarea.selectionStart; - const emojiChar = data.native; - this._restoreCaret = position + emojiChar.length + 1; - this.props.onPickEmoji(position, data); - } - - render () { - const { intl, onPaste, showSearch } = this.props; - const disabled = this.props.is_submitting; - const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : ''; - const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join(''); - - const secondaryVisibility = this.props.settings.get('side_arm'); - let showSideArm = secondaryVisibility !== 'none'; - - let publishText = ''; - let publishText2 = ''; - let title = ''; - let title2 = ''; - - const privacyIcons = { - none: '', - public: 'globe', - unlisted: 'unlock-alt', - private: 'lock', - direct: 'envelope', - }; - - title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`; - - if (showSideArm) { - // Enhanced behavior with dual toot buttons - publishText = ( - <span> - { - <i - className={`fa fa-${privacyIcons[this.props.privacy]}`} - style={{ paddingRight: '5px' }} - /> - }{intl.formatMessage(messages.publish)} - </span> - ); - - title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`; - publishText2 = ( - <i - className={`fa fa-${privacyIcons[secondaryVisibility]}`} - aria-label={title2} - /> - ); - } else { - // Original vanilla behavior - no icon if public or unlisted - if (this.props.privacy === 'private' || this.props.privacy === 'direct') { - publishText = <span className='compose-form__publish-private'><i className='fa fa-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); - } - } - - const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0); - - return ( - <div className='compose-form'> - <Collapsable isVisible={this.props.spoiler} fullHeight={50}> - <div className='spoiler-input'> - <label> - <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span> - <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' /> - </label> - </div> - </Collapsable> - - <WarningContainer /> - - <ReplyIndicatorContainer /> - - <div className='compose-form__autosuggest-wrapper'> - <AutosuggestTextarea - ref={this.setAutosuggestTextarea} - placeholder={intl.formatMessage(messages.placeholder)} - disabled={disabled} - value={this.props.text} - onChange={this.handleChange} - suggestions={this.props.suggestions} - onKeyDown={this.handleKeyDown} - onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} - onSuggestionsClearRequested={this.onSuggestionsClearRequested} - onSuggestionSelected={this.onSuggestionSelected} - onPaste={onPaste} - autoFocus={!showSearch && !isMobile(window.innerWidth)} - /> - - <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} /> - </div> - - <div className='compose-form__modifiers'> - <UploadFormContainer /> - </div> - - <div className='compose-form__buttons'> - <ComposeAttachOptions /> - <SensitiveButtonContainer /> - <div className='compose-form__buttons-separator' /> - <PrivacyDropdownContainer /> - <SpoilerButtonContainer /> - <ComposeAdvancedOptionsContainer /> - </div> - - <div className='compose-form__publish'> - <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div> - <div className='compose-form__publish-button-wrapper'> - { - showSideArm ? - <Button - className='compose-form__publish__side-arm' - text={publishText2} - title={title2} - onClick={this.handleSubmit2} - disabled={submitDisabled} - /> : '' - } - <Button - className='compose-form__publish__primary' - text={publishText} - title={title} - onClick={this.handleSubmit} - disabled={submitDisabled} - /> - </div> - </div> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js deleted file mode 100644 index 1b0000fb7..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ /dev/null @@ -1,77 +0,0 @@ -// Package imports. -import React from 'react'; -import PropTypes from 'prop-types'; - -// Our imports. -import IconButton from 'flavours/glitch/components/icon_button'; - -const iconStyle = { - height : null, - lineHeight : '27px', -}; - -export default class ComposeDropdown extends React.PureComponent { - - static propTypes = { - title: PropTypes.string.isRequired, - icon: PropTypes.string, - highlight: PropTypes.bool, - disabled: PropTypes.bool, - children: PropTypes.arrayOf(PropTypes.node).isRequired, - }; - - state = { - open: false, - }; - - onGlobalClick = (e) => { - if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { - this.setState({ open: false }); - } - }; - - componentDidMount () { - window.addEventListener('click', this.onGlobalClick); - window.addEventListener('touchstart', this.onGlobalClick); - } - componentWillUnmount () { - window.removeEventListener('click', this.onGlobalClick); - window.removeEventListener('touchstart', this.onGlobalClick); - } - - onToggleDropdown = () => { - if (this.props.disabled) return; - this.setState({ open: !this.state.open }); - }; - - setRef = (c) => { - this.node = c; - }; - - render () { - const { open } = this.state; - let { highlight, title, icon, disabled } = this.props; - - if (!icon) icon = 'ellipsis-h'; - - return ( - <div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${highlight ? 'active' : ''} `}> - <div className='advanced-options-dropdown__value'> - <IconButton - className={'inverted'} - title={title} - icon={icon} active={open || highlight} - size={18} - style={iconStyle} - disabled={disabled} - onClick={this.onToggleDropdown} - /> - </div> - <div className='advanced-options-dropdown__dropdown'> - {this.props.children} - </div> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js deleted file mode 100644 index 90f062f8f..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js +++ /dev/null @@ -1,200 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { injectIntl, defineMessages } from 'react-intl'; -import IconButton from 'flavours/glitch/components/icon_button'; -import Overlay from 'react-overlays/lib/Overlay'; -import Motion from 'flavours/glitch/util/optional_motion'; -import spring from 'react-motion/lib/spring'; -import detectPassiveEvents from 'detect-passive-events'; -import classNames from 'classnames'; - -const messages = defineMessages({ - public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, - public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, - unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, - unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, - private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, - private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, - direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, - direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, - change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, -}); - -const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; - -class PrivacyDropdownMenu extends React.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(); - } - } - - handleClick = e => { - if (e.key === 'Escape') { - this.props.onClose(); - } else if (!e.key || e.key === 'Enter') { - const value = e.currentTarget.getAttribute('data-index'); - - e.preventDefault(); - - this.props.onClose(); - this.props.onChange(value); - } - } - - componentDidMount () { - document.addEventListener('click', this.handleDocumentClick, false); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - componentWillUnmount () { - document.removeEventListener('click', this.handleDocumentClick, false); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - } - - render () { - const { style, items, value } = this.props; - - return ( - <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> - {({ opacity, scaleX, scaleY }) => ( - <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> - {items.map(item => - <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}> - <div className='privacy-dropdown__option__icon'> - <i className={`fa fa-fw fa-${item.icon}`} /> - </div> - - <div className='privacy-dropdown__option__content'> - <strong>{item.text}</strong> - {item.meta} - </div> - </div> - )} - </div> - )} - </Motion> - ); - } - -} - -@injectIntl -export default class PrivacyDropdown extends React.PureComponent { - - static propTypes = { - isUserTouching: PropTypes.func, - isModalOpen: PropTypes.bool.isRequired, - onModalOpen: PropTypes.func, - onModalClose: PropTypes.func, - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - state = { - open: false, - }; - - handleToggle = () => { - if (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 { - this.setState({ open: !this.state.open }); - } - } - - handleModalActionClick = (e) => { - e.preventDefault(); - - const { value } = this.options[e.currentTarget.getAttribute('data-index')]; - - this.props.onModalClose(); - this.props.onChange(value); - } - - handleKeyDown = e => { - switch(e.key) { - case 'Enter': - this.handleToggle(); - break; - case 'Escape': - this.handleClose(); - break; - } - } - - handleClose = () => { - this.setState({ open: false }); - } - - handleChange = value => { - this.props.onChange(value); - } - - componentWillMount () { - const { intl: { formatMessage } } = this.props; - - this.options = [ - { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, - { icon: 'unlock-alt', 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: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, - ]; - } - - render () { - const { value, intl } = this.props; - const { open } = this.state; - - const valueOption = this.options.find(item => item.value === value); - - return ( - <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}> - <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}> - <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} - style={{ height: null, lineHeight: '27px' }} - /> - </div> - - <Overlay show={open} placement='bottom' target={this}> - <PrivacyDropdownMenu - items={this.options} - value={value} - onClose={this.handleClose} - onChange={this.handleChange} - /> - </Overlay> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js deleted file mode 100644 index 3048d591b..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PropTypes from 'prop-types'; -import Avatar from 'flavours/glitch/components/avatar'; -import IconButton from 'flavours/glitch/components/icon_button'; -import DisplayName from 'flavours/glitch/components/display_name'; -import { defineMessages, injectIntl } from 'react-intl'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { isRtl } from 'flavours/glitch/util/rtl'; - -const messages = defineMessages({ - cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, -}); - -@injectIntl -export default 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.preventDefault(); - this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); - } - } - - render () { - const { status, intl } = this.props; - - if (!status) { - return null; - } - - const content = { __html: status.get('contentHtml') }; - const style = { - direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr', - }; - - 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} /></div> - - <a href={status.getIn(['account', 'url'])} 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' style={style} dangerouslySetInnerHTML={content} /> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js deleted file mode 100644 index a1fc93234..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/upload.js +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PropTypes from 'prop-types'; -import IconButton from 'flavours/glitch/components/icon_button'; -import Motion from 'flavours/glitch/util/optional_motion'; -import spring from 'react-motion/lib/spring'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; -import classNames from 'classnames'; - -const messages = defineMessages({ - undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, - description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, -}); - -@injectIntl -export default class Upload extends ImmutablePureComponent { - - static propTypes = { - media: ImmutablePropTypes.map.isRequired, - intl: PropTypes.object.isRequired, - onUndo: PropTypes.func.isRequired, - onDescriptionChange: PropTypes.func.isRequired, - }; - - state = { - hovered: false, - focused: false, - dirtyDescription: null, - }; - - handleUndoClick = () => { - this.props.onUndo(this.props.media.get('id')); - } - - handleInputChange = e => { - this.setState({ dirtyDescription: e.target.value }); - } - - handleMouseEnter = () => { - this.setState({ hovered: true }); - } - - handleMouseLeave = () => { - this.setState({ hovered: false }); - } - - handleInputFocus = () => { - this.setState({ focused: true }); - } - - handleInputBlur = () => { - const { dirtyDescription } = this.state; - - this.setState({ focused: false, dirtyDescription: null }); - - if (dirtyDescription !== null) { - this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); - } - } - - render () { - const { intl, media } = this.props; - const active = this.state.hovered || this.state.focused; - const description = this.state.dirtyDescription || media.get('description') || ''; - - return ( - <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> - <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')})` }}> - <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> - - <div className={classNames('compose-form__upload-description', { active })}> - <label> - <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> - - <input - placeholder={intl.formatMessage(messages.description)} - type='text' - value={description} - maxLength={420} - onFocus={this.handleInputFocus} - onChange={this.handleInputChange} - onBlur={this.handleInputBlur} - /> - </label> - </div> - </div> - )} - </Motion> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_button.js b/app/javascript/flavours/glitch/features/compose/components/upload_button.js deleted file mode 100644 index f06167a2a..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/upload_button.js +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import IconButton from 'flavours/glitch/components/icon_button'; -import PropTypes from 'prop-types'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -const messages = defineMessages({ - upload: { id: 'upload_button.label', defaultMessage: 'Add media' }, -}); - -const makeMapStateToProps = () => { - const mapStateToProps = state => ({ - acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), - }); - - return mapStateToProps; -}; - -const iconStyle = { - height: null, - lineHeight: '27px', -}; - -@connect(makeMapStateToProps) -@injectIntl -export default class UploadButton extends ImmutablePureComponent { - - static propTypes = { - disabled: PropTypes.bool, - onSelectFile: PropTypes.func.isRequired, - style: PropTypes.object, - resetFileKey: PropTypes.number, - acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired, - intl: PropTypes.object.isRequired, - }; - - handleChange = (e) => { - if (e.target.files.length > 0) { - this.props.onSelectFile(e.target.files); - } - } - - handleClick = () => { - this.fileElement.click(); - } - - setRef = (c) => { - this.fileElement = c; - } - - render () { - - const { intl, resetFileKey, disabled, acceptContentTypes } = this.props; - - return ( - <div className='compose-form__upload-button'> - <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} /> - <label> - <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span> - <input - key={resetFileKey} - ref={this.setRef} - type='file' - multiple={false} - accept={acceptContentTypes.toArray().join(',')} - onChange={this.handleChange} - disabled={disabled} - style={{ display: 'none' }} - /> - </label> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js deleted file mode 100644 index b7f112205..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import UploadProgressContainer from '../containers/upload_progress_container'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import UploadContainer from '../containers/upload_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> - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js deleted file mode 100644 index 2a3b8ceb4..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Motion from 'flavours/glitch/util/optional_motion'; -import spring from 'react-motion/lib/spring'; -import { FormattedMessage } from 'react-intl'; - -export default class UploadProgress extends React.PureComponent { - - static propTypes = { - active: PropTypes.bool, - progress: PropTypes.number, - }; - - render () { - const { active, progress } = this.props; - - if (!active) { - return null; - } - - return ( - <div className='upload-progress'> - <div className='upload-progress__icon'> - <i className='fa fa-upload' /> - </div> - - <div className='upload-progress__message'> - <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> - - <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> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js deleted file mode 100644 index 4962e76c8..000000000 --- a/app/javascript/flavours/glitch/features/compose/components/warning.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Motion from 'flavours/glitch/util/optional_motion'; -import spring from 'react-motion/lib/spring'; - -export default class Warning extends React.PureComponent { - - static propTypes = { - message: PropTypes.node.isRequired, - }; - - render () { - const { message } = this.props; - - return ( - <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> - {({ opacity, scaleX, scaleY }) => ( - <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> - {message} - </div> - )} - </Motion> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js b/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js deleted file mode 100644 index da381568b..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js +++ /dev/null @@ -1,20 +0,0 @@ -// Package imports. -import { connect } from 'react-redux'; - -// Our imports. -import { toggleComposeAdvancedOption } from 'flavours/glitch/actions/compose'; -import ComposeAdvancedOptions from '../components/advanced_options'; - -const mapStateToProps = state => ({ - values: state.getIn(['compose', 'advanced_options']), -}); - -const mapDispatchToProps = dispatch => ({ - - onChange (option) { - dispatch(toggleComposeAdvancedOption(option)); - }, - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions); diff --git a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js deleted file mode 100644 index 0e1c328fe..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js +++ /dev/null @@ -1,15 +0,0 @@ -import { connect } from 'react-redux'; -import AutosuggestAccount from '../components/autosuggest_account'; -import { makeGetAccount } from 'flavours/glitch/selectors'; - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, { id }) => ({ - account: getAccount(state, id), - }); - - return mapStateToProps; -}; - -export default connect(makeMapStateToProps)(AutosuggestAccount); diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js deleted file mode 100644 index e2e93e44b..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ /dev/null @@ -1,71 +0,0 @@ -import { connect } from 'react-redux'; -import ComposeForm from '../components/compose_form'; -import { changeComposeVisibility, uploadCompose } from 'flavours/glitch/actions/compose'; -import { - changeCompose, - submitCompose, - clearComposeSuggestions, - fetchComposeSuggestions, - selectComposeSuggestion, - changeComposeSpoilerText, - insertEmojiCompose, -} from 'flavours/glitch/actions/compose'; - -const mapStateToProps = state => ({ - text: state.getIn(['compose', 'text']), - suggestion_token: state.getIn(['compose', 'suggestion_token']), - suggestions: state.getIn(['compose', 'suggestions']), - advanced_options: state.getIn(['compose', 'advanced_options']), - spoiler: state.getIn(['compose', 'spoiler']), - spoiler_text: state.getIn(['compose', 'spoiler_text']), - privacy: state.getIn(['compose', 'privacy']), - focusDate: state.getIn(['compose', 'focusDate']), - preselectDate: state.getIn(['compose', 'preselectDate']), - is_submitting: state.getIn(['compose', 'is_submitting']), - is_uploading: state.getIn(['compose', 'is_uploading']), - showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), - settings: state.get('local_settings'), - filesAttached: state.getIn(['compose', 'media_attachments']).size > 0, -}); - -const mapDispatchToProps = (dispatch) => ({ - - onChange (text) { - dispatch(changeCompose(text)); - }, - - onPrivacyChange (value) { - dispatch(changeComposeVisibility(value)); - }, - - onSubmit () { - dispatch(submitCompose()); - }, - - onClearSuggestions () { - dispatch(clearComposeSuggestions()); - }, - - onFetchSuggestions (token) { - dispatch(fetchComposeSuggestions(token)); - }, - - onSuggestionSelected (position, token, accountId) { - dispatch(selectComposeSuggestion(position, token, accountId)); - }, - - onChangeSpoilerText (checked) { - dispatch(changeComposeSpoilerText(checked)); - }, - - onPaste (files) { - dispatch(uploadCompose(files)); - }, - - onPickEmoji (position, data) { - dispatch(insertEmojiCompose(position, data)); - }, - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm); diff --git a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js deleted file mode 100644 index ba85edd87..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js +++ /dev/null @@ -1,82 +0,0 @@ -import { connect } from 'react-redux'; -import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; -import { changeSetting } from 'flavours/glitch/actions/settings'; -import { createSelector } from 'reselect'; -import { Map as ImmutableMap } from 'immutable'; -import { useEmoji } from 'flavours/glitch/actions/emojis'; - -const perLine = 8; -const lines = 2; - -const DEFAULTS = [ - '+1', - 'grinning', - 'kissing_heart', - 'heart_eyes', - 'laughing', - 'stuck_out_tongue_winking_eye', - 'sweat_smile', - 'joy', - 'yum', - 'disappointed', - 'thinking_face', - 'weary', - 'sob', - 'sunglasses', - 'heart', - 'ok_hand', -]; - -const getFrequentlyUsedEmojis = createSelector([ - state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), -], emojiCounters => { - let emojis = emojiCounters - .keySeq() - .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b)) - .reverse() - .slice(0, perLine * lines) - .toArray(); - - if (emojis.length < DEFAULTS.length) { - emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length)); - } - - return emojis; -}); - -const getCustomEmojis = createSelector([ - state => state.get('custom_emojis'), -], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { - const aShort = a.get('shortcode').toLowerCase(); - const bShort = b.get('shortcode').toLowerCase(); - - if (aShort < bShort) { - return -1; - } else if (aShort > bShort ) { - return 1; - } else { - return 0; - } -})); - -const mapStateToProps = state => ({ - custom_emojis: getCustomEmojis(state), - skinTone: state.getIn(['settings', 'skinTone']), - frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), -}); - -const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ - onSkinTone: skinTone => { - dispatch(changeSetting(['skinTone'], skinTone)); - }, - - onPickEmoji: emoji => { - dispatch(useEmoji(emoji)); - - if (onPickEmoji) { - onPickEmoji(emoji); - } - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown); diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js deleted file mode 100644 index cb94fcc80..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import PrivacyDropdown from '../components/privacy_dropdown'; -import { changeComposeVisibility } from 'flavours/glitch/actions/compose'; -import { openModal, closeModal } from 'flavours/glitch/actions/modal'; -import { isUserTouching } from 'flavours/glitch/util/is_mobile'; - -const mapStateToProps = state => ({ - isModalOpen: state.get('modal').modalType === 'ACTIONS', - value: state.getIn(['compose', 'privacy']), -}); - -const mapDispatchToProps = dispatch => ({ - - onChange (value) { - dispatch(changeComposeVisibility(value)); - }, - - isUserTouching, - onModalOpen: props => dispatch(openModal('ACTIONS', props)), - onModalClose: () => dispatch(closeModal()), - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown); diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js deleted file mode 100644 index a7c82d135..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import { cancelReplyCompose } from 'flavours/glitch/actions/compose'; -import { makeGetStatus } from 'flavours/glitch/selectors'; -import ReplyIndicator from '../components/reply_indicator'; - -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); - - const mapStateToProps = state => ({ - status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = dispatch => ({ - - onCancel () { - dispatch(cancelReplyCompose()); - }, - -}); - -export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator); diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js deleted file mode 100644 index cf6706c0e..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import IconButton from 'flavours/glitch/components/icon_button'; -import { changeComposeSensitivity } from 'flavours/glitch/actions/compose'; -import Motion from 'flavours/glitch/util/optional_motion'; -import spring from 'react-motion/lib/spring'; -import { injectIntl, defineMessages } from 'react-intl'; - -const messages = defineMessages({ - title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }, -}); - -const mapStateToProps = state => ({ - visible: state.getIn(['compose', 'media_attachments']).size > 0, - active: state.getIn(['compose', 'sensitive']), - disabled: state.getIn(['compose', 'spoiler']), -}); - -const mapDispatchToProps = dispatch => ({ - - onClick () { - dispatch(changeComposeSensitivity()); - }, - -}); - -class SensitiveButton extends React.PureComponent { - - static propTypes = { - visible: PropTypes.bool, - active: PropTypes.bool, - disabled: PropTypes.bool, - onClick: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - render () { - const { visible, active, disabled, onClick, intl } = this.props; - - return ( - <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}> - {({ scale }) => { - const icon = active ? 'eye-slash' : 'eye'; - const className = classNames('compose-form__sensitive-button', { - 'compose-form__sensitive-button--visible': visible, - }); - return ( - <div className={className} style={{ transform: `scale(${scale})` }}> - <IconButton - className='compose-form__sensitive-button__icon' - title={intl.formatMessage(messages.title)} - icon={icon} - onClick={onClick} - size={18} - active={active} - disabled={disabled} - style={{ lineHeight: null, height: null }} - inverted - /> - </div> - ); - }} - </Motion> - ); - } - -} - -export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); diff --git a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js deleted file mode 100644 index d7b4246bc..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import TextIconButton from '../components/text_icon_button'; -import { changeComposeSpoilerness } from 'flavours/glitch/actions/compose'; -import { injectIntl, defineMessages } from 'react-intl'; - -const messages = defineMessages({ - title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' }, -}); - -const mapStateToProps = (state, { intl }) => ({ - label: 'CW', - title: intl.formatMessage(messages.title), - active: state.getIn(['compose', 'spoiler']), - ariaControls: 'cw-spoiler-input', -}); - -const mapDispatchToProps = dispatch => ({ - - onClick () { - dispatch(changeComposeSpoilerness()); - }, - -}); - -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton)); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js deleted file mode 100644 index 4c1cb49e9..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js +++ /dev/null @@ -1,18 +0,0 @@ -import { connect } from 'react-redux'; -import UploadButton from '../components/upload_button'; -import { uploadCompose } from 'flavours/glitch/actions/compose'; - -const mapStateToProps = state => ({ - disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')), - resetFileKey: state.getIn(['compose', 'resetFileKey']), -}); - -const mapDispatchToProps = dispatch => ({ - - onSelectFile (files) { - dispatch(uploadCompose(files)); - }, - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(UploadButton); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js deleted file mode 100644 index 368038425..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js +++ /dev/null @@ -1,21 +0,0 @@ -import { connect } from 'react-redux'; -import Upload from '../components/upload'; -import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose'; - -const mapStateToProps = (state, { id }) => ({ - media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), -}); - -const mapDispatchToProps = dispatch => ({ - - onUndo: id => { - dispatch(undoUploadCompose(id)); - }, - - onDescriptionChange: (id, description) => { - dispatch(changeUploadCompose(id, description)); - }, - -}); - -export default connect(mapStateToProps, mapDispatchToProps)(Upload); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js deleted file mode 100644 index a6798bf51..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js +++ /dev/null @@ -1,8 +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); diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js deleted file mode 100644 index 0cfee96da..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js +++ /dev/null @@ -1,9 +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']), -}); - -export default connect(mapStateToProps)(UploadProgress); diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js deleted file mode 100644 index f20c75b91..000000000 --- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import Warning from '../components/warning'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import { me } from 'flavours/glitch/util/initial_state'; - -const mapStateToProps = state => ({ - needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), -}); - -const WarningWrapper = ({ needsLockWarning }) => { - if (needsLockWarning) { - return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; - } - - return null; -}; - -WarningWrapper.propTypes = { - needsLockWarning: PropTypes.bool, -}; - -export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js deleted file mode 100644 index 63c9500b1..000000000 --- a/app/javascript/flavours/glitch/features/compose/index.js +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; -import ComposeFormContainer from './containers/compose_form_container'; -import NavigationContainer from './containers/navigation_container'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; -import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose'; -import { openModal } from 'flavours/glitch/actions/modal'; -import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; -import { Link } from 'react-router-dom'; -import { injectIntl, defineMessages } from 'react-intl'; -import SearchContainer from './containers/search_container'; -import Motion from 'flavours/glitch/util/optional_motion'; -import spring from 'react-motion/lib/spring'; -import SearchResultsContainer from './containers/search_results_container'; -import { changeComposing } from 'flavours/glitch/actions/compose'; - -const messages = defineMessages({ - start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, - home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, - notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, - public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, - community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, - settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, - logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, -}); - -const mapStateToProps = state => ({ - columns: state.getIn(['settings', 'columns']), - showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), -}); - -@connect(mapStateToProps) -@injectIntl -export default class Compose extends React.PureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - columns: ImmutablePropTypes.list.isRequired, - multiColumn: PropTypes.bool, - showSearch: PropTypes.bool, - intl: PropTypes.object.isRequired, - }; - - componentDidMount () { - this.props.dispatch(mountCompose()); - } - - componentWillUnmount () { - this.props.dispatch(unmountCompose()); - } - - onLayoutClick = (e) => { - const layout = e.currentTarget.getAttribute('data-mastodon-layout'); - this.props.dispatch(changeLocalSetting(['layout'], layout)); - e.preventDefault(); - } - - openSettings = () => { - this.props.dispatch(openModal('SETTINGS', {})); - } - - onFocus = () => { - this.props.dispatch(changeComposing(true)); - } - - onBlur = () => { - this.props.dispatch(changeComposing(false)); - } - - render () { - const { multiColumn, showSearch, intl } = this.props; - - let header = ''; - - if (multiColumn) { - const { columns } = this.props; - header = ( - <nav className='drawer__header'> - <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link> - {!columns.some(column => column.get('id') === 'HOME') && ( - <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></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)}><i role='img' className='fa fa-fw fa-bell' /></Link> - )} - {!columns.some(column => column.get('id') === 'COMMUNITY') && ( - <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><i role='img' className='fa fa-fw fa-users' /></Link> - )} - {!columns.some(column => column.get('id') === 'PUBLIC') && ( - <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link> - )} - <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a> - <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a> - </nav> - ); - } - - - - return ( - <div className='drawer'> - {header} - - <SearchContainer /> - - <div className='drawer__pager'> - <div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}> - <NavigationContainer onClose={this.onBlur} /> - <ComposeFormContainer /> - </div> - - <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> - {({ x }) => - <div className='drawer__inner darker scrollable optionally-scrollable' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> - <SearchResultsContainer /> - </div> - } - </Motion> - </div> - - </div> - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js new file mode 100644 index 000000000..25c2622d8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/index.js @@ -0,0 +1,440 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl } from 'react-intl'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router'; + +// Actions. +import { + cancelReplyCompose, + changeCompose, + changeComposeSensitivity, + changeComposeSpoilerText, + changeComposeSpoilerness, + changeComposeVisibility, + changeUploadCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + insertEmojiCompose, + selectComposeSuggestion, + submitCompose, + toggleComposeAdvancedOption, + undoUploadCompose, + uploadCompose, +} from 'flavours/glitch/actions/compose'; +import { + closeModal, + openModal, +} from 'flavours/glitch/actions/modal'; + +// Components. +import ComposerOptions from './options'; +import ComposerPublisher from './publisher'; +import ComposerReply from './reply'; +import ComposerSpoiler from './spoiler'; +import ComposerTextarea from './textarea'; +import ComposerUploadForm from './upload_form'; +import ComposerWarning from './warning'; + +// Utils. +import { countableText } from 'flavours/glitch/util/counter'; +import { me } from 'flavours/glitch/util/initial_state'; +import { isMobile } from 'flavours/glitch/util/is_mobile'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; +import { mergeProps } from 'flavours/glitch/util/redux_helpers'; + +// State mapping. +function mapStateToProps (state) { + const inReplyTo = state.getIn(['compose', 'in_reply_to']); + return { + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), + amUnlocked: !state.getIn(['accounts', me, 'locked']), + doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']), + focusDate: state.getIn(['compose', 'focusDate']), + isSubmitting: state.getIn(['compose', 'is_submitting']), + isUploading: state.getIn(['compose', 'is_uploading']), + media: state.getIn(['compose', 'media_attachments']), + preselectDate: state.getIn(['compose', 'preselectDate']), + privacy: state.getIn(['compose', 'privacy']), + progress: state.getIn(['compose', 'progress']), + replyAccount: inReplyTo ? state.getIn(['accounts', state.getIn(['statuses', inReplyTo, 'account'])]) : null, + replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null, + resetFileKey: state.getIn(['compose', 'resetFileKey']), + sideArm: state.getIn(['local_settings', 'side_arm']), + sensitive: state.getIn(['compose', 'sensitive']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + spoiler: state.getIn(['compose', 'spoiler']), + spoilerText: state.getIn(['compose', 'spoiler_text']), + suggestionToken: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + text: state.getIn(['compose', 'text']), + }; +}; + +// Dispatch mapping. +const mapDispatchToProps = dispatch => ({ + cancelReply () { + dispatch(cancelReplyCompose()); + }, + changeDescription (mediaId, description) { + dispatch(changeUploadCompose(mediaId, description)); + }, + changeSensitivity () { + dispatch(changeComposeSensitivity()); + }, + changeSpoilerText (checked) { + dispatch(changeComposeSpoilerText(checked)); + }, + changeSpoilerness () { + dispatch(changeComposeSpoilerness()); + }, + changeText (text) { + dispatch(changeCompose(text)); + }, + changeVisibility (value) { + dispatch(changeComposeVisibility(value)); + }, + clearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + closeModal () { + dispatch(closeModal()); + }, + fetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + insertEmoji (position, data) { + dispatch(insertEmojiCompose(position, data)); + }, + openActionsModal (data) { + dispatch(openModal('ACTIONS', data)); + }, + openDoodleModal () { + dispatch(openModal('DOODLE', { noEsc: true })); + }, + selectSuggestion (position, token, accountId) { + dispatch(selectComposeSuggestion(position, token, accountId)); + }, + submit () { + dispatch(submitCompose()); + }, + toggleAdvancedOption (option) { + dispatch(toggleComposeAdvancedOption(option)); + }, + undoUpload (mediaId) { + dispatch(undoUploadCompose(mediaId)); + }, + upload (files) { + dispatch(uploadCompose(files)); + }, +}); + +// Handlers. +const handlers = { + + // Changes the text value of the spoiler. + changeSpoiler ({ target: { value } }) { + const { dispatch: { changeSpoilerText } } = this.props; + if (changeSpoilerText) { + changeSpoilerText(value); + } + }, + + // Inserts an emoji at the caret. + emoji (data) { + const { textarea: { selectionStart } } = this; + const { dispatch: { insertEmoji } } = this.props; + this.caretPos = selectionStart + data.native.length + 1; + if (insertEmoji) { + insertEmoji(selectionStart, data); + } + }, + + // Handles the secondary submit button. + secondarySubmit () { + const { submit } = this.handlers; + const { + dispatch: { changeVisibility }, + side_arm, + } = this.props; + if (changeVisibility) { + changeVisibility(side_arm); + } + submit(); + }, + + // Selects a suggestion from the autofill. + select (tokenStart, token, value) { + const { dispatch: { selectSuggestion } } = this.props; + this.caretPos = null; + if (selectSuggestion) { + selectSuggestion(tokenStart, token, value); + } + }, + + // Submits the status. + submit () { + const { textarea: { value } } = this; + const { + dispatch: { + changeText, + submit, + }, + state: { text }, + } = this.props; + + // If something changes inside the textarea, then we update the + // state before submitting. + if (changeText && text !== value) { + changeText(value); + } + + // Submits the status. + if (submit) { + submit(); + } + }, + + // Sets a reference to the textarea. + refTextarea ({ textarea }) { + this.textarea = textarea; + }, +}; + +// The component. +@injectIntl +@connect(mapStateToProps, mapDispatchToProps, mergeProps) +export default class Composer extends React.Component { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + + // Instance variables. + this.caretPos = null; + this.textarea = null; + } + + // If this is the update where we've finished uploading, + // save the last caret position so we can restore it below! + componentWillReceiveProps (nextProps) { + const { textarea: { selectionStart } } = this; + const { state: { isUploading } } = this.props; + if (isUploading && !nextProps.state.isUploading) { + this.caretPos = selectionStart; + } + } + + // This statement does several things: + // - If we're beginning a reply, and, + // - Replying to zero or one users, places the cursor at the end + // of the textbox. + // - Replying to more than one user, selects any usernames past + // the first; this provides a convenient shortcut to drop + // everyone else from the conversation. + // - If we've just finished uploading an image, and have a saved + // caret position, restores the cursor to that position after the + // text changes. + componentDidUpdate (prevProps) { + const { + caretPos, + textarea, + } = this; + const { + state: { + focusDate, + isUploading, + isSubmitting, + preselectDate, + text, + }, + } = this.props; + let selectionEnd, selectionStart; + + // Caret/selection handling. + if (focusDate !== prevProps.state.focusDate || (prevProps.state.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) { + switch (true) { + case preselectDate !== prevProps.state.preselectDate: + selectionStart = text.search(/\s/) + 1; + selectionEnd = text.length; + break; + case !isNaN(caretPos) && caretPos !== null: + selectionStart = selectionEnd = caretPos; + break; + default: + selectionStart = selectionEnd = text.length; + } + textarea.setSelectionRange(selectionStart, selectionEnd); + textarea.focus(); + + // Refocuses the textarea after submitting. + } else if (prevProps.state.isSubmitting && !isSubmitting) { + textarea.focus(); + } + } + + render () { + const { + changeSpoiler, + emoji, + secondarySubmit, + select, + submit, + refTextarea, + } = this.handlers; + const { history } = this.context; + const { + dispatch: { + cancelReply, + changeDescription, + changeSensitivity, + changeText, + changeVisibility, + clearSuggestions, + closeModal, + fetchSuggestions, + openActionsModal, + openDoodleModal, + toggleAdvancedOption, + undoUpload, + upload, + }, + intl, + state: { + acceptContentTypes, + amUnlocked, + doNotFederate, + isSubmitting, + isUploading, + media, + privacy, + progress, + replyAccount, + replyContent, + resetFileKey, + sensitive, + showSearch, + sideArm, + spoiler, + spoilerText, + suggestions, + text, + }, + } = this.props; + + return ( + <div className='compose'> + <ComposerSpoiler + hidden={!spoiler} + intl={intl} + onChange={changeSpoiler} + onSubmit={submit} + text={spoilerText} + /> + {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null} + {replyContent ? ( + <ComposerReply + account={replyAccount} + content={replyContent} + history={history} + intl={intl} + onCancel={cancelReply} + /> + ) : null} + <ComposerTextarea + autoFocus={!showSearch && !isMobile(window.innerWidth)} + disabled={isSubmitting} + intl={intl} + onChange={changeText} + onPaste={upload} + onPickEmoji={emoji} + onSubmit={submit} + onSuggestionsClearRequested={clearSuggestions} + onSuggestionsFetchRequested={fetchSuggestions} + onSuggestionSelected={select} + ref={refTextarea} + suggestions={suggestions} + value={text} + /> + {media && media.size ? ( + <ComposerUploadForm + active={isUploading} + intl={intl} + media={media} + onChangeDescription={changeDescription} + onRemove={undoUpload} + progress={progress} + /> + ) : null} + <ComposerOptions + acceptContentTypes={acceptContentTypes} + disabled={isSubmitting} + doNotFederate={doNotFederate} + full={media.size >= 4 || media.some( + item => item.get('type') === 'video' + )} + hasMedia={!!media.size} + intl={intl} + onChangeSensitivity={changeSensitivity} + onChangeVisibility={changeVisibility} + onDoodleOpen={openDoodleModal} + onModalClose={closeModal} + onModalOpen={openActionsModal} + onToggleAdvancedOption={toggleAdvancedOption} + onUpload={upload} + privacy={privacy} + resetFileKey={resetFileKey} + sensitive={sensitive} + spoiler={spoiler} + /> + <ComposerPublisher + countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`} + disabled={isSubmitting || isUploading || text.length && text.trim().length === 0} + intl={intl} + onSecondarySubmit={secondarySubmit} + onSubmit={submit} + privacy={privacy} + sideArm={sideArm} + /> + </div> + ); + } + +} + +// Context +Composer.contextTypes = { + history: PropTypes.object, +} + +// Props. +Composer.propTypes = { + dispatch: PropTypes.objectOf(PropTypes.func).isRequired, + intl: PropTypes.object.isRequired, + state: PropTypes.shape({ + acceptContentTypes: PropTypes.string, + amUnlocked: PropTypes.bool, + doNotFederate: PropTypes.bool, + focusDate: PropTypes.instanceOf(Date), + isSubmitting: PropTypes.bool, + isUploading: PropTypes.bool, + media: PropTypes.list, + preselectDate: PropTypes.instanceOf(Date), + privacy: PropTypes.string, + progress: PropTypes.number, + replyAccount: ImmutablePropTypes.map, + replyContent: PropTypes.string, + resetFileKey: PropTypes.string, + sideArm: PropTypes.string, + sensitive: PropTypes.bool, + showSearch: PropTypes.bool, + spoiler: PropTypes.bool, + spoilerText: PropTypes.string, + suggestionToken: PropTypes.string, + suggestions: ImmutablePropTypes.list, + text: PropTypes.string, + }).isRequired, +}; diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js new file mode 100644 index 000000000..0f304bc88 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js @@ -0,0 +1,243 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import spring from 'react-motion/lib/spring'; +import Overlay from 'react-overlays/lib/Overlay'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import ComposerOptionsDropdownItem from './item'; + +// Utils. +import { withPassive } from 'flavours/glitch/util/dom_helpers'; +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; +import Motion from 'flavours/glitch/util/optional_motion'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// We'll use this to define our various transitions. +const springMotion = spring(1, { + damping: 35, + stiffness: 400, +}); + +// Handlers. +const handlers = { + + // Closes the dropdown. + close () { + this.setState({ open: false }); + }, + + // When the document is clicked elsewhere, we close the dropdown. + documentClick ({ target }) { + const { node } = this; + const { onClose } = this.props; + if (onClose && node && !node.contains(target)) { + onClose(); + } + }, + + // The enter key toggles the dropdown's open state, and the escape + // key closes it. + keyDown ({ key }) { + const { + close, + toggle, + } = this.handlers; + switch (key) { + case 'Enter': + toggle(); + break; + case 'Escape': + close(); + break; + } + }, + + // Toggles opening and closing the dropdown. + toggle () { + const { + items, + onChange, + onModalClose, + onModalOpen, + value, + } = this.props; + const { open } = this.state; + + // If this is a touch device, we open a modal instead of the + // dropdown. + if (onModalClose && isUserTouching()) { + if (open) { + onModalClose() + } else if (onChange && onModalOpen) { + onModalOpen({ + actions: items.map( + ({ + name, + ...rest + }) => ({ + ...rest, + active: value && name === value, + onClick (e) { + e.preventDefault(); // Prevents focus from changing + onModalClose(); + onChange(name); + }, + }) + ), + }); + } + + // Otherwise, we just set our state to open. + } else { + this.setState({ open: !open }); + } + }, + + // Stores our node in `this.node`. + ref (node) { + this.node = node; + }, +}; + +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + this.state = { open: false }; + + // Instance variables. + this.node = null; + } + + // On mounting, we add our listeners. + componentDidMount () { + const { documentClick } = this.handlers; + document.addEventListener('click', documentClick, false); + document.addEventListener('touchend', documentClick, withPassive); + } + + // On unmounting, we remove our listeners. + componentWillUnmount () { + const { documentClick } = this.handlers; + document.removeEventListener('click', documentClick, false); + document.removeEventListener('touchend', documentClick, withPassive); + } + + // Rendering. + render () { + const { + close, + keyDown, + ref, + toggle, + } = this.handlers; + const { + active, + disabled, + title, + icon, + items, + onChange, + value, + } = this.props; + const { open } = this.state; + const computedClass = classNames('composer--options--dropdown', { + active, + open: open || active, + }); + + // The result. + return ( + <div + className={computedClass} + onKeyDown={keyDown} + ref={ref} + > + <IconButton + active={open || active} + className='value' + disabled={disabled} + icon={icon} + onClick={toggle} + size={18} + style={{ + height: null, + lineHeight: '27px', + }} + title={title} + /> + <Overlay + placement='bottom' + show={open} + target={this} + > + <Motion + defaultStyle={{ + opacity: 0, + scaleX: 0.85, + scaleY: 0.75, + }} + style={{ + opacity: springMotion, + scaleX: springMotion, + scaleY: springMotion, + }} + > + {({ opacity, scaleX, scaleY }) => ( + <div + className='dropdown' + ref={this.setRef} + style={{ + opacity: opacity, + transform: `scale(${scaleX}, ${scaleY})`, + }} + > + {items.map( + ({ + name, + ...rest + }) => ( + <ComposerOptionsDropdownItem + active={name === value} + key={name} + name={name} + onChange={onChange} + onClose={close} + options={rest} + /> + ) + )} + </div> + )} + </Motion> + </Overlay> + </div> + ); + } + +} + +// Props. +ComposerOptionsDropdown.propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + icon: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })).isRequired, + onChange: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js new file mode 100644 index 000000000..ca4ee393e --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js @@ -0,0 +1,126 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Toggle from 'react-toggle'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Handlers. +const handlers = { + + // This function activates the dropdown item. + activate (e) { + const { + name, + onChange, + onClose, + options: { on }, + } = this.props; + + // If the escape key was pressed, we close the dropdown. + if (e.key === 'Escape' && onClose) { + onClose(); + + // Otherwise, we both close the dropdown and change the value. + } else if (onChange && (!e.key || e.key === 'Enter')) { + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined') && onClose) { + onClose(); + } + onChange(name); + } + }, + +}; + +// The component. +export default class ComposerOptionsDropdownItem extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { activate } = this.handlers; + const { + active, + options: { + icon, + meta, + on, + text, + }, + } = this.props; + const computedClass = classNames('composer--options--dropdown_item', { + active, + lengthy: meta, + 'toggled-off': !on && on !== null && typeof on !== 'undefined', + 'toggled-on': on, + 'with-icon': icon, + }); + + // The result. + return ( + <div + className={computedClass} + onClick={activate} + onKeyDown={activate} + role='button' + tabIndex='0' + > + {function () { + + // We render a `<Toggle>` if we were provided an `on` + // property, and otherwise show an `<Icon>` if available. + switch (true) { + case on !== null && typeof on !== 'undefined': + return ( + <Toggle + checked={on} + onChange={activate} + /> + ); + case !!icon: + return ( + <Icon + fullwidth + icon={icon} + /> + ); + default: + return null; + } + }()} + {meta ? ( + <div> + <strong>{text}</strong> + {meta} + </div> + ) : <div>{text}</div>} + </div> + ); + } + +}; + +// Props. +ComposerOptionsDropdownItem.propTypes = { + active: PropTypes.bool, + name: PropTypes.string, + onChange: PropTypes.func, + onClose: PropTypes.func, + options: PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + on: PropTypes.bool, + text: PropTypes.node, + }), +}; diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js new file mode 100644 index 000000000..ee633e865 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/options/index.js @@ -0,0 +1,321 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import { + FormattedMessage, + defineMessages, +} from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import TextIconButton from 'flavours/glitch/components/text_icon_button'; +import Dropdown from './dropdown'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; +import { + assignHandlers, + hiddenComponent, +} from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + advanced_options_icon_title: { + defaultMessage: 'Advanced options', + id: 'advanced_options.icon_title', + }, + attach: { + defaultMessage: 'Attach...', + id: 'compose.attach', + }, + change_privacy: { + defaultMessage: 'Adjust status privacy', + id: 'privacy.change', + }, + direct_long: { + defaultMessage: 'Post to mentioned users only', + id: 'privacy.direct.long', + }, + direct_short: { + defaultMessage: 'Direct', + id: 'privacy.direct.short', + }, + doodle: { + defaultMessage: 'Draw something', + id: 'compose.attach.doodle', + }, + local_only_long: { + defaultMessage: 'Do not post to other instances', + id: 'advanced-options.local-only.long', + }, + local_only_short: { + defaultMessage: 'Local-only', + id: 'advanced-options.local-only.short', + }, + private_long: { + defaultMessage: 'Post to followers only', + id: 'privacy.private.long', + }, + private_short: { + defaultMessage: 'Followers-only', + id: 'privacy.private.short', + }, + public_long: { + defaultMessage: 'Post to public timelines', + id: 'privacy.public.long', + }, + public_short: { + defaultMessage: 'Public', + id: 'privacy.public.short', + }, + sensitive: { + defaultMessage: 'Mark media as sensitive', + id: 'compose_form.sensitive', + }, + spoiler: { + defaultMessage: 'Hide text behind warning', + id: 'compose_form.spoiler', + }, + unlisted_long: { + defaultMessage: 'Do not show in public timelines', + id: 'privacy.unlisted.long', + }, + unlisted_short: { + defaultMessage: 'Unlisted', + id: 'privacy.unlisted.short', + }, + upload: { + defaultMessage: 'Upload a file', + id: 'compose.attach.upload', + }, +}); + +// Handlers. +const handlers = { + + // Handles file selection. + changeFiles ({ target: { files } }) { + const { onUpload } = this.props; + if (files.length && onUpload) { + onUpload(files); + } + }, + + // Handles attachment clicks. + clickAttach (name) { + const { fileElement } = this; + const { onDoodleOpen } = this.props; + + // We switch over the name of the option. + switch (name) { + case 'upload': + if (fileElement) { + fileElement.click(); + } + return; + case 'doodle': + if (onDoodleOpen) { + onDoodleOpen(); + } + return; + } + }, + + // Handles a ref to the file input. + refFileElement (fileElement) { + this.fileElement = fileElement; + }, +}; + +// The component. +export default class ComposerOptions extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + + // Instance variables. + this.fileElement = null; + } + + // Rendering. + render () { + const { + changeFiles, + clickAttach, + refFileElement, + } = this.handlers; + const { + acceptContentTypes, + disabled, + doNotFederate, + full, + hasMedia, + intl, + onChangeSensitivity, + onChangeVisibility, + onModalClose, + onModalOpen, + onToggleAdvancedOption, + privacy, + resetFileKey, + sensitive, + spoiler, + } = this.props; + + // We predefine our privacy items so that we can easily pick the + // dropdown icon later. + const privacyItems = { + direct: { + icon: 'envelope', + meta: <FormattedMessage {...messages.direct_long} />, + name: 'direct', + text: <FormattedMessage {...messages.direct_short} />, + }, + private: { + icon: 'lock', + meta: <FormattedMessage {...messages.private_long} />, + name: 'private', + text: <FormattedMessage {...messages.private_short} />, + }, + public: { + icon: 'globe', + meta: <FormattedMessage {...messages.public_long} />, + name: 'public', + text: <FormattedMessage {...messages.public_short} />, + }, + unlisted: { + icon: 'unlock-alt', + meta: <FormattedMessage {...messages.unlisted_long} />, + name: 'unlisted', + text: <FormattedMessage {...messages.unlisted_short} />, + }, + }; + + // The result. + return ( + <div className='composer--options'> + <input + accept={acceptContentTypes} + disabled={disabled || full} + key={resetFileKey} + onChange={changeFiles} + ref={refFileElement} + type='file' + {...hiddenComponent} + /> + <Dropdown + disabled={disabled || full} + icon='paperclip' + items={[ + { + icon: 'cloud-upload', + name: 'upload', + text: <FormattedMessage {...messages.upload} />, + }, + { + icon: 'paint-brush', + name: 'doodle', + text: <FormattedMessage {...messages.doodle} />, + }, + ]} + onChange={clickAttach} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={messages.attach} + /> + <Motion + defaultStyle={{ scale: 0.87 }} + style={{ + scale: spring(hasMedia ? 1 : 0.87, { + stiffness: 200, + damping: 3, + }), + }} + > + {({ scale }) => ( + <div style={{ transform: `scale(${scale})` }}> + <IconButton + active={sensitive} + className='sensitive' + disabled={spoiler} + icon={sensitive ? 'eye-slash' : 'eye'} + inverted + onClick={onChangeSensitivity} + size={18} + style={{ + height: null, + lineHeight: null, + }} + title={intl.formatMessage(messages.sensitive)} + /> + </div> + )} + </Motion> + <hr /> + <Dropdown + disabled={disabled} + icon={(privacyItems[privacy] || {}).icon} + items={[ + privacyItems.public, + privacyItems.unlisted, + privacyItems.private, + privacyItems.direct, + ]} + onChange={onChangeVisibility} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.change_privacy)} + value={privacy} + /> + <TextIconButton + active={spoiler} + ariaControls='glitch.composer.spoiler.input' + label='CW' + title={intl.formatMessage(messages.spoiler)} + /> + <Dropdown + active={doNotFederate} + disabled={disabled} + icon='home' + items={[ + { + meta: <FormattedMessage {...messages.local_only_long} />, + name: 'do_not_federate', + on: doNotFederate, + text: <FormattedMessage {...messages.local_only_short} />, + }, + ]} + onChange={onToggleAdvancedOption} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.advanced_options_icon_title)} + /> + </div> + ); + } + +} + +// Props. +ComposerOptions.propTypes = { + acceptContentTypes: PropTypes.string, + disabled: PropTypes.bool, + doNotFederate: PropTypes.bool, + full: PropTypes.bool, + hasMedia: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChangeSensitivity: PropTypes.func, + onChangeVisibility: PropTypes.func, + onDoodleOpen: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + onToggleAdvancedOption: PropTypes.func, + onUpload: PropTypes.func, + privacy: PropTypes.string, + resetFileKey: PropTypes.string, + sensitive: PropTypes.bool, + spoiler: PropTypes.bool, +}; diff --git a/app/javascript/flavours/glitch/features/composer/publisher/index.js b/app/javascript/flavours/glitch/features/composer/publisher/index.js new file mode 100644 index 000000000..85de80a9f --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/publisher/index.js @@ -0,0 +1,119 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { + defineMessages, + FormattedMessage, +} from 'react-intl'; +import { length } from 'stringz'; + +// Components. +import Button from 'flavours/glitch/components/button'; +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { maxChars } from 'flavours/glitch/util/initial_state'; + +// Messages. +const messages = defineMessages({ + publish: { + defaultMessage: 'Toot', + id: 'compose_form.publish', + }, + publishLoud: { + defaultMessage: '{publish}!', + id: 'compose_form.publish_loud', + }, +}); + +// The component. +export default function ComposerPublisher ({ + countText, + disabled, + intl, + onSecondarySubmit, + onSubmit, + privacy, + sideArm, +}) { + const diff = maxChars - length(countText || ''); + const computedClass = classNames('composer--publisher', { + disabled: disabled || diff < 0, + over: diff < 0, + }); + + // The result. + return ( + <div className={computedClass}> + <span class='count'>{diff}</span> + {sideArm && sideArm !== 'none' ? ( + <Button + text={ + <span> + <Icon + icon={{ + public: 'globe', + unlisted: 'unlock-alt', + private: 'lock', + direct: 'envelope', + }[sideArm]} + /> + </span> + } + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`} + onClick={onSecondarySubmit} + disabled={disabled || diff < 0} + /> + ) : null} + <Button + className='compose-form__publish__primary' + text={function () { + switch (true) { + case !!sideArm && sideArm !== 'none': + case privacy === 'direct': + case privacy === 'private': + return ( + <span> + <Icon + icon={{ + direct: 'envelope', + private: 'lock', + public: 'globe', + unlisted: 'unlock-alt', + }[privacy]} + /> + <FormattedMessage {...messages.publish} /> + </span> + ); + case privacy === 'public': + return ( + <span> + <FormattedMessage + {...messages.publishLoud} + values={{ publish: <FormattedMessage {...messages.publish} /> }} + /> + </span> + ); + default: + return <span><FormattedMessage {...messages.publish} /></span>; + } + }()} + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`} + onClick={onSubmit} + disabled={disabled || diff < 0} + /> + </div> + ); +} + +// Props. +ComposerPublisher.propTypes = { + countText: PropTypes.string, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onSecondarySubmit: PropTypes.func, + onSubmit: PropTypes.func, + privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), + sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), +}; diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js new file mode 100644 index 000000000..2823415d2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/reply/index.js @@ -0,0 +1,106 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages } from 'react-intl'; + +// Components. +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; +import IconButton from 'flavours/glitch/components/icon_button'; + +// Utils. +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; +import { isRtl } from 'flavours/glitch/util/rtl'; + +// Messages. +const messages = defineMessages({ + cancel: { + defaultMessage: 'Cancel', + id: 'reply_indicator.cancel', + }, +}); + +// Handlers. +const handlers = { + + // Handles a click on the "close" button. + click () { + const { onCancel } = this.props; + if (onCancel) { + onCancel(); + } + }, + + // Handles a click on the status's account. + clickAccount () { + const { + account, + history, + } = this.props; + if (history) { + history.push(`/accounts/${account.get('id')}`); + } + }, +}; + +// The component. +export default class ComposerReply extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { + click, + clickAccount, + } = this.handlers; + const { + account, + content, + intl, + } = this.props; + + // The result. + return ( + <article className='composer--reply'> + <header> + <IconButton + icon='times' + onClick={click} + title={intl.formatMessage(messages.cancel)} + /> + {account ? ( + <a + href={account.get('url')} + onClick={clickAccount} + > + <Avatar + account={account} + size={24} + /> + <DisplayName account={account} /> + </a> + ) : null} + </header> + <div + dangerouslySetInnerHTML={{ __html: content || '' }} + style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }} + /> + </article> + ); + } + +} + +ComposerReply.propTypes = { + account: ImmutablePropTypes.map, + content: PropTypes.string, + history: PropTypes.object, + intl: PropTypes.object.isRequired, + onCancel: PropTypes.func, +}; diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js new file mode 100644 index 000000000..730ab2205 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/spoiler/index.js @@ -0,0 +1,92 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage } from 'react-intl'; + +// Components. +import Collapsable from 'flavours/glitch/components/collapsable'; + +// Utils. +import { + assignHandlers, + hiddenComponent, +} from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + placeholder: { + defaultMessage: 'Write your warning here', + id: 'compose_form.spoiler_placeholder', + }, +}); + +// Handlers. +const handlers = { + + // Handles a keypress. + keyDown ({ + ctrlKey, + keyCode, + metaKey, + }) { + const { onSubmit } = this.props; + + // We submit the status on control/meta + enter. + if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) { + onSubmit(); + } + }, +}; + +// The component. +export default class ComposerSpoiler extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { keyDown } = this.handlers; + const { + hidden, + intl, + onChange, + text, + } = this.props; + + // The result. + return ( + <Collapsable + isVisible={!hidden} + fullHeight={50} + > + <label className='composer--spoiler'> + <span {...hiddenComponent}> + <FormattedMessage {...messages.placeholder} /> + </span> + <input + id='glitch.composer.spoiler.input' + onChange={onChange} + onKeyDown={keyDown} + placeholder={intl.formatMessage(messages.placeholder)} + type='text' + value={text} + /> + </label> + </Collapsable> + ); + } + +} + +// Props. +ComposerSpoiler.propTypes = { + hidden: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChange: PropTypes.func, + onSubmit: PropTypes.func, + text: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js new file mode 100644 index 000000000..ad0a35d7f --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js @@ -0,0 +1,297 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { + defineMessages, + FormattedMessage, +} from 'react-intl'; +import Textarea from 'react-textarea-autosize'; + +// Components. +import EmojiPicker from 'flavours/glitch/features/emoji_picker'; +import ComposerTextareaSuggestions from './suggestions'; + +// Utils. +import { isRtl } from 'flavours/glitch/util/rtl'; +import { + assignHandlers, + hiddenComponent, +} from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + placeholder: { + defaultMessage: 'What is on your mind?', + id: 'compose_form.placeholder', + }, +}); + +// Handlers. +const handlers = { + + // When blurring the textarea, suggestions are hidden. + blur () { + this.setState({ suggestionsHidden: true }); + }, + + // When the contents of the textarea change, we have to pull up new + // autosuggest suggestions if applicable, and also change the value + // of the textarea in our store. + change ({ + target: { + selectionStart, + value, + }, + }) { + const { + onChange, + onSuggestionsFetchRequested, + onSuggestionsClearRequested, + } = this.props; + const { lastToken } = this.state; + + // This gets the token at the caret location, if it begins with an + // `@` (mentions) or `:` (shortcodes). + const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/); + const right = value.slice(selectionStart).search(/[\s\u200B]/); + const token = function () { + switch (true) { + case left < 0 || /[@:]/.test(!value[left]): + return null; + case right < 0: + return value.slice(left); + default: + return value.slice(left, right + selectionStart).trim().toLowerCase(); + } + }(); + + // We only request suggestions for tokens which are at least 3 + // characters long. + if (onSuggestionsFetchRequested && token && token.length >= 3) { + if (lastToken !== token) { + this.setState({ + lastToken: token, + selectedSuggestion: 0, + tokenStart: left, + }); + onSuggestionsFetchRequested(token); + } + } else { + this.setState({ lastToken: null }); + if (onSuggestionsClearRequested) { + onSuggestionsClearRequested(); + } + } + + // Updates the value of the textarea. + if (onChange) { + onChange(value); + } + }, + + // Handles a click on an autosuggestion. + clickSuggestion (index) { + const { textarea } = this; + const { + onSuggestionSelected, + suggestions, + } = this.props; + const { + lastToken, + tokenStart, + } = this.state; + onSuggestionSelected(tokenStart, lastToken, suggestions.get(index)); + textarea.focus(); + }, + + // Handles a keypress. If the autosuggestions are visible, we need + // to allow keypresses to navigate and sleect them. + keyDown (e) { + const { + disabled, + onSubmit, + onSuggestionSelected, + suggestions, + } = this.props; + const { + lastToken, + suggestionsHidden, + selectedSuggestion, + tokenStart, + } = this.state; + + // Keypresses do nothing if the composer is disabled. + if (disabled) { + e.preventDefault(); + return; + } + + // Switches over the pressed key. + switch(e.key) { + + // On arrow down, we pick the next suggestion. + case 'ArrowDown': + if (suggestions && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + return; + + // On arrow up, we pick the previous suggestion. + case 'ArrowUp': + if (suggestions && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + return; + + // On enter or tab, we select the suggestion. + case 'Enter': + case 'Tab': + if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion)); + } + return; + } + + // We submit the status on control/meta + enter. + if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + onSubmit(); + } + }, + + // When the escape key is released, we either close the suggestions + // window or focus the UI. + keyUp ({ key }) { + const { suggestionsHidden } = this.state; + if (key === 'Escape') { + if (!suggestionsHidden) { + this.setState({ suggestionsHidden: true }); + } else { + document.querySelector('.ui').parentElement.focus(); + } + } + }, + + // Handles the pasting of images into the composer. + paste (e) { + const { onPaste } = this.props; + let d; + if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) { + onPaste(d); + e.preventDefault(); + } + }, + + // Saves a reference to the textarea. + refTextarea (textarea) { + this.textarea = textarea; + }, +}; + +// The component. +export default class ComposerTextarea extends React.Component { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + this.state = { + suggestionsHidden: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + // Instance variables. + this.textarea = null; + } + + // When we receive new suggestions, we unhide the suggestions window + // if we didn't have any suggestions before. + componentWillReceiveProps (nextProps) { + const { suggestions } = this.props; + const { suggestionsHidden } = this.state; + if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) { + this.setState({ suggestionsHidden: false }); + } + } + + // Rendering. + render () { + const { + blur, + change, + clickSuggestion, + keyDown, + keyUp, + paste, + refTextarea, + } = this.handlers; + const { + autoFocus, + disabled, + intl, + onPickEmoji, + suggestions, + value, + } = this.props; + const { + selectedSuggestion, + suggestionsHidden, + } = this.state; + + // The result. + return ( + <div className='autosuggest-textarea'> + <label> + <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span> + <Textarea + aria-autocomplete='list' + autoFocus={autoFocus} + disabled={disabled} + inputRef={refTextarea} + onBlur={blur} + onChange={change} + onKeyDown={keyDown} + onKeyUp={keyUp} + onPaste={paste} + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }} + /> + </label> + <EmojiPicker onPickEmoji={onPickEmoji} /> + <ComposerTextareaSuggestions + hidden={suggestionsHidden} + onSuggestionClick={clickSuggestion} + suggestions={suggestions} + value={selectedSuggestion} + /> + </div> + ); + } + +} + +// Props. +ComposerTextarea.propTypes = { + autoFocus: PropTypes.bool, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChange: PropTypes.func, + onPaste: PropTypes.func, + onPickEmoji: PropTypes.func, + onSubmit: PropTypes.func, + onSuggestionsClearRequested: PropTypes.func, + onSuggestionsFetchRequested: PropTypes.func, + onSuggestionSelected: PropTypes.func, + suggestions: ImmutablePropTypes.list, + value: PropTypes.string, +}; + +// Default props. +ComposerTextarea.defaultProps = { autoFocus: true }; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js new file mode 100644 index 000000000..b90696910 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js @@ -0,0 +1,41 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Components. +import ComposerTextareaSuggestionsItem from './item'; + +// The component. +export default function ComposerTextareaSuggestions ({ + hidden, + onSuggestionClick, + suggestions, + value, +}) { + const computedClass = classNames('comoser--textarea--suggestions', { hidden: hidden || suggestions.isEmpty() }); + + return ( + <div className={computedClass}> + {!hidden ? suggestions.map( + (suggestion, index) => ( + <ComposerTextareaSuggestionsItem + index={index} + key={typeof suggestion === 'object' ? suggestion.id : suggestion} + onClick={onSuggestionClick} + selected={index === value} + suggestion={suggestion} + /> + ) + ) : null} + </div> + ); +} + +ComposerTextareaSuggestions.propTypes = { + hidden: PropTypes.bool, + onSuggestionClick: PropTypes.func, + suggestions: ImmutablePropTypes.list, + value: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js new file mode 100644 index 000000000..08c99ed0e --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js @@ -0,0 +1,101 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// Components. +import AccountContainer from 'flavours/glitch/containers/account_container'; + +// Utils. +import { unicodeMapping } from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Gets our asset host from the environment, if available. +const assetHost = ((process || {}).env || {}).CDN_HOST || ''; + +// Handlers. +const handlers = { + + // Handles a click on a suggestion. + click (e) { + const { + index, + onClick, + } = this.props; + if (onClick) { + e.preventDefault(); + onClick(index); + } + }, +}; + +// The component. +export default class ComposerTextareaSuggestionsItem extends React.Component { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { click } = this.handlers; + const { + selected, + suggestion, + } = this.props; + const computedClass = classNames('composer--textarea--suggestions--item', { selected }); + + // The result. + return ( + <div + role='button' + tabIndex='0' + className={computedClass} + onMouseDown={click} + > + { // If the suggestion is an object, then we render an emoji. + // Otherwise, we render an account. + typeof suggestion === 'object' ? function () { + const url = function () { + if (suggestion.custom) { + return suggestion.imageUrl; + } else { + const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')]; + if (!mapping) { + return null; + } + return `${assetHost}/emoji/${mapping.filename}.svg`; + } + }(); + return url ? ( + <div className='emoji'> + <img + alt={suggestion.native || suggestion.colons} + className='emojione' + src={url} + /> + {suggestion.colons} + </div> + ) : null; + }() : ( + <AccountContainer + id={suggestion} + small + /> + ) + } + </div> + ); + } + +} + +// Props. +ComposerTextareaSuggestionsItem.propTypes = { + index: PropTypes.number, + onClick: PropTypes.func, + selected: PropTypes.bool, + suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), +}; diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/index.js new file mode 100644 index 000000000..ab46a3046 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/upload_form/index.js @@ -0,0 +1,54 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Components. +import ComposerUploadFormItem from './item'; +import ComposerUploadFormProgress from './progress'; + +// The component. +export default function ComposerUploadForm ({ + active, + intl, + media, + onChangeDescription, + onRemove, + progress, +}) { + const computedClass = classNames('composer--upload_form', { uploading: active }); + + // We need `media` in order to be able to render. + if (!media) { + return null; + } + + // The result. + return ( + <div className={computedClass}> + {active ? <ComposerUploadFormProgress progress={progress} /> : null} + {media.map(item => ( + <ComposerUploadFormItem + description={item.get('description')} + key={item.get('id')} + id={item.get('id')} + intl={intl} + preview={item.get('preview_url')} + onChangeDescription={onChangeDescription} + onRemove={onRemove} + /> + ))} + </div> + ); +} + +// Props. +ComposerUploadForm.propTypes = { + active: PropTypes.bool, + intl: PropTypes.object.isRequired, + media: ImmutablePropTypes.list, + onChangeDescription: PropTypes.func, + onRemove: PropTypes.func, + progress: PropTypes.number, +}; diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js new file mode 100644 index 000000000..bd67e7227 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js @@ -0,0 +1,176 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { + FormattedMessage, + defineMessages, +} from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + undo: { + defaultMessage: 'Undo', + id: 'upload_form.undo', + }, + description: { + defaultMessage: 'Describe for the visually impaired', + id: 'upload_form.description', + }, +}); + +// Handlers. +const handlers = { + + // On blur, we save the description for the media item. + blur () { + const { + id, + onChangeDescription, + } = this.props; + const { dirtyDescription } = this.state; + if (id && onChangeDescription && dirtyDescription !== null) { + this.setState({ + dirtyDescription: null, + focused: false, + }); + onChangeDescription(id, dirtyDescription); + } + }, + + // When the value of our description changes, we store it in the + // temp value `dirtyDescription` in our state. + change ({ target: { value } }) { + this.setState({ dirtyDescription: value }); + }, + + // Records focus on the media item. + focus () { + this.setState({ focused: true }); + }, + + // Records the start of a hover over the media item. + mouseEnter () { + this.setState({ hovered: true }); + }, + + // Records the end of a hover over the media item. + mouseLeave () { + this.setState({ hovered: false }); + }, + + // Removes the media item. + remove () { + const { + id, + onRemove, + } = this.props; + if (id && onRemove) { + onRemove(id); + } + }, +}; + +// The component. +export default class ComposerUploadFormItem extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(handlers); + this.state = { + hovered: false, + focused: false, + dirtyDescription: null, + }; + } + + // Rendering. + render () { + const { + blur, + change, + focus, + mouseEnter, + mouseLeave, + remove, + } = this.handlers; + const { + description, + intl, + preview, + } = this.props; + const { + focused, + hovered, + dirtyDescription, + } = this.state; + const computedClass = classNames('composer--upload_form--item', { active: hovered || focused }); + + // The result. + return ( + <div + className={computedClass} + onMouseEnter={mouseEnter} + onMouseLeave={mouseLeave} + > + <Motion + defaultStyle={{ scale: 0.8 }} + style={{ + scale: spring(1, { + stiffness: 180, + damping: 12, + }), + }} + > + {({ scale }) => ( + <div + style={{ + transform: `scale(${scale})`, + backgroundImage: preview ? `url(${preview})` : null, + }} + > + <IconButton + icon='times' + onClick={remove} + size={36} + title={intl.formatMessage(messages.undo)} + /> + <label> + <span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span> + <input + maxLength={420} + onBlur={blur} + onChange={change} + onFocus={focus} + placeholder={intl.formatMessage(messages.description)} + type='text' + value={dirtyDescription || description || ''} + /> + </label> + </div> + )} + </Motion> + </div> + ); + } + +} + +// Props. +ComposerUploadFormItem.propTypes = { + description: PropTypes.string, + id: PropTypes.number, + intl: PropTypes.object.isRequired, + onChangeDescription: PropTypes.func, + onRemove: PropTypes.func, + preview: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js new file mode 100644 index 000000000..9dac6acf9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js @@ -0,0 +1,52 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import { + defineMessages, + FormattedMessage, +} from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; + +// Messages. +const messages = defineMessages({ + upload: { + defaultMessage: 'Uploading...', + id: 'upload_progress.label', + }, +}); + +// The component. +export default function ComposerUploadFormProgress ({ progress }) { + + // The result. + return ( + <div className='composer--upload_form--progress'> + <Icon icon='upload' /> + <div className='message'> + <FormattedMessage {...messages.upload} /> + <div className='backdrop'> + <Motion + defaultStyle={{ width: 0 }} + style={{ width: spring(progress) }} + > + {({ width }) => + <div + className='tracker' + style={{ width: `${width}%` }} + /> + } + </Motion> + </div> + </div> + </div> + ); +} + +// Props. +ComposerUploadFormProgress.propTypes = { progress: PropTypes.number }; diff --git a/app/javascript/flavours/glitch/features/composer/warning/index.js b/app/javascript/flavours/glitch/features/composer/warning/index.js new file mode 100644 index 000000000..c225b50e8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/warning/index.js @@ -0,0 +1,54 @@ +import React from 'react'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { defineMessages, FormattedMessage } from 'react-intl'; + +// This is the spring used with our motion. +const motionSpring = spring(1, { damping: 35, stiffness: 400 }); + +// Messages. +const messages = defineMessages({ + disclaimer: { + defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.', + id: 'compose_form.lock_disclaimer', + }, + locked: { + defaultMessage: 'locked', + id: 'compose_form.lock_disclaimer.lock', + }, +}); + +// The component. +export default function ComposerWarning () { + return ( + <Motion + defaultStyle={{ + opacity: 0, + scaleX: 0.85, + scaleY: 0.75, + }} + style={{ + opacity: motionSpring, + scaleX: motionSpring, + scaleY: motionSpring, + }} + > + {({ opacity, scaleX, scaleY }) => ( + <div + className='composer--warning' + style={{ + opacity: opacity, + transform: `scale(${scaleX}, ${scaleY})`, + }} + > + <FormattedMessage + {...messages.disclaimer} + values={{ locked: <a href='/settings/profile'><FormattedMessage {...messages.locked} /></a> }} + /> + </div> + )} + </Motion> + ); +} + +ComposerWarning.propTypes = {}; diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js index 1b6d74123..1b6d74123 100644 --- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js +++ b/app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/drawer/components/search.js index 1ce66b19d..1ce66b19d 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search.js +++ b/app/javascript/flavours/glitch/features/drawer/components/search.js diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/drawer/components/search_results.js index 2a4818d4e..2a4818d4e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/search_results.js +++ b/app/javascript/flavours/glitch/features/drawer/components/search_results.js diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js index eb630ffbb..eb630ffbb 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js +++ b/app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/drawer/containers/search_container.js index 8f4bfcf08..8f4bfcf08 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/search_container.js +++ b/app/javascript/flavours/glitch/features/drawer/containers/search_container.js diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js index 16d95d417..16d95d417 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js +++ b/app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js diff --git a/app/javascript/flavours/glitch/features/drawer/index.js b/app/javascript/flavours/glitch/features/drawer/index.js new file mode 100644 index 000000000..8386ae47c --- /dev/null +++ b/app/javascript/flavours/glitch/features/drawer/index.js @@ -0,0 +1,198 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; +import spring from 'react-motion/lib/spring'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; + +// Actions. +import { changeComposing } from 'flavours/glitch/actions/compose'; +import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; +import { openModal } from 'flavours/glitch/actions/modal'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; +import Compose from 'flavours/glitch/features/compose'; +import NavigationContainer from './containers/navigation_container'; +import SearchContainer from './containers/search_container'; +import SearchResultsContainer from './containers/search_results_container'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; +import { + assignHandlers, + conditionalRender, +} from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + community: { + defaultMessage: 'Local timeline', + id: 'navigation_bar.community_timeline', + }, + home_timeline: { + defaultMessage: 'Home', + id: 'tabs_bar.home', + }, + logout: { + defaultMessage: 'Logout', + id: 'navigation_bar.logout', + }, + notifications: { + defaultMessage: 'Notifications', + id: 'tabs_bar.notifications', + }, + public: { + defaultMessage: 'Federated timeline', + id: 'navigation_bar.public_timeline', + }, + settings: { + defaultMessage: 'App settings', + id: 'navigation_bar.app_settings', + }, + start: { + defaultMessage: 'Getting started', + id: 'getting_started.heading', + }, +}); + +// State mapping. +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), +}); + +// Dispatch mapping. +const mapDispatchToProps = dispatch => ({ + onBlur () { + dispatch(changeComposing(false)); + }, + onFocus () { + dispatch(changeComposing(true)); + }, + onSettingsOpen () { + dispatch(openModal('SETTINGS', {})); + }, +}); + +// The component. +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default function Drawer ({ + columns, + intl, + multiColumn, + onBlur, + onFocus, + onSettingsOpen, + showSearch, +}) { + + // Only renders the component if the column isn't being shown. + const renderForColumn = conditionalRender.bind( + columnId => !columns.some(column => column.get('id') === columnId) + ); + + // The result. + return ( + <div className='drawer'> + {multiColumn ? ( + <nav className='drawer__header'> + <Link + aria-label={intl.formatMessage(messages.start)} + className='drawer__tab' + title={intl.formatMessage(messages.start)} + to='/getting-started' + ><Icon icon='asterisk' /></Link> + {renderForColumn('HOME', ( + <Link + aria-label={intl.formatMessage(messages.home_timeline)} + className='drawer__tab' + title={intl.formatMessage(messages.home_timeline)} + to='/timelines/home' + ><Icon icon='home' /></Link> + ))} + {renderForColumn('NOTIFICATIONS', ( + <Link + aria-label={intl.formatMessage(messages.notifications)} + className='drawer__tab' + title={intl.formatMessage(messages.notifications)} + to='/notifications' + ><Icon icon='bell' /></Link> + ))} + {renderForColumn('COMMUNITY', ( + <Link + aria-label={intl.formatMessage(messages.community)} + className='drawer__tab' + title={intl.formatMessage(messages.community)} + to='/timelines/public/local' + ><Icon icon='users' /></Link> + ))} + {renderForColumn('PUBLIC', ( + <Link + aria-label={intl.formatMessage(messages.public)} + className='drawer__tab' + title={intl.formatMessage(messages.public)} + to='/timelines/public' + ><Icon icon='globe' /></Link> + ))} + <a + aria-label={intl.formatMessage(messages.settings)} + className='drawer__tab' + onClick={settings} + role='button' + title={intl.formatMessage(messages.settings)} + tabIndex='0' + ><Icon icon='cogs' /></a> + <a + aria-label={intl.formatMessage(messages.logout)} + className='drawer__tab' + data-method='delete' + href='/auth/sign_out' + title={intl.formatMessage(messages.logout)} + ><Icon icon='sign-out' /></a> + </nav> + ) : null} + <SearchContainer /> + <div className='drawer__pager'> + <div + className='drawer__inner scrollable optionally-scrollable' + onFocus={focus} + > + <NavigationContainer onClose={blur} /> + <Compose /> + </div> + <Motion + defaultStyle={{ x: -100 }} + style={{ + x: spring(showSearch ? 0 : -100, { + stiffness: 210, + damping: 20, + }) + }} + > + {({ x }) => ( + <div + className='drawer__inner darker scrollable optionally-scrollable' + style={{ + transform: `translateX(${x}%)`, + visibility: x === -100 ? 'hidden' : 'visible' + }} + ><SearchResultsContainer /></div> + )} + </Motion> + </div> + </div> + ); +} + +// Props. +Drawer.propTypes = { + dispatch: PropTypes.func.isRequired, + columns: ImmutablePropTypes.list.isRequired, + multiColumn: PropTypes.bool, + showSearch: PropTypes.bool, + intl: PropTypes.object.isRequired, +}; diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js index cf89f91d3..4b1ef6c97 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -1,3 +1,8 @@ +import { connect } from 'react-redux'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; +import { useEmoji } from 'flavours/glitch/actions/emojis'; import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; @@ -25,6 +30,80 @@ const messages = defineMessages({ flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, }); +const perLine = 8; +const lines = 2; + +const DEFAULTS = [ + '+1', + 'grinning', + 'kissing_heart', + 'heart_eyes', + 'laughing', + 'stuck_out_tongue_winking_eye', + 'sweat_smile', + 'joy', + 'yum', + 'disappointed', + 'thinking_face', + 'weary', + 'sob', + 'sunglasses', + 'heart', + 'ok_hand', +]; + +const getFrequentlyUsedEmojis = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()), +], emojiCounters => { + let emojis = emojiCounters + .keySeq() + .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b)) + .reverse() + .slice(0, perLine * lines) + .toArray(); + + if (emojis.length < DEFAULTS.length) { + emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length)); + } + + return emojis; +}); + +const getCustomEmojis = createSelector([ + state => state.get('custom_emojis'), +], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => { + const aShort = a.get('shortcode').toLowerCase(); + const bShort = b.get('shortcode').toLowerCase(); + + if (aShort < bShort) { + return -1; + } else if (aShort > bShort ) { + return 1; + } else { + return 0; + } +})); + +const mapStateToProps = state => ({ + custom_emojis: getCustomEmojis(state), + skinTone: state.getIn(['settings', 'skinTone']), + frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), +}); + +const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ + onSkinTone: skinTone => { + dispatch(changeSetting(['skinTone'], skinTone)); + }, + + onPickEmoji: emoji => { + dispatch(useEmoji(emoji)); + + if (onPickEmoji) { + onPickEmoji(emoji); + } + }, +}); + const assetHost = process.env.CDN_HOST || ''; let EmojiPicker, Emoji; // load asynchronously @@ -277,6 +356,7 @@ class EmojiPickerMenu extends React.PureComponent { } +@connect(mapStateToProps, mapDispatchToProps) @injectIntl export default class EmojiPickerDropdown extends React.PureComponent { diff --git a/app/javascript/flavours/glitch/features/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js index b33c21cb5..a77b59448 100644 --- a/app/javascript/flavours/glitch/features/standalone/compose/index.js +++ b/app/javascript/flavours/glitch/features/standalone/compose/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container'; +import Composer from 'flavours/glitch/features/composer'; import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container'; import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container'; import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container'; @@ -9,7 +9,7 @@ export default class Compose extends React.PureComponent { render () { return ( <div> - <ComposeFormContainer /> + <Composer /> <NotificationsContainer /> <ModalContainer /> <LoadingBarContainer className='loading-bar' /> diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index aaa36b696..52a8ab5ec 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -134,7 +134,7 @@ function removeMedia(state, mediaId) { const insertSuggestion = (state, position, token, completion) => { return state.withMutations(map => { - map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`); + map.update('text', oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`); map.set('suggestion_token', null); map.update('suggestions', ImmutableList(), list => list.clear()); map.set('focusDate', new Date()); diff --git a/app/javascript/flavours/glitch/styles/components/compose.scss b/app/javascript/flavours/glitch/styles/components/compose.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/compose.scss diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/drawer.scss diff --git a/app/javascript/flavours/glitch/util/dom_helpers.js b/app/javascript/flavours/glitch/util/dom_helpers.js new file mode 100644 index 000000000..ee95ef8dd --- /dev/null +++ b/app/javascript/flavours/glitch/util/dom_helpers.js @@ -0,0 +1,6 @@ +// Package imports. +import detectPassiveEvents from 'detect-passive-events'; + +// This will either be a passive lister options object (if passive +// events are supported), or `false`. +export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false; diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index 607d6b9b0..530bca7ef 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -18,5 +18,6 @@ export const boostModal = getMeta('boost_modal'); export const favouriteModal = getMeta('favourite_modal'); export const deleteModal = getMeta('delete_modal'); export const me = getMeta('me'); +export const maxChars = getMeta('max_toot_chars') || 500; export default initialState; diff --git a/app/javascript/flavours/glitch/util/react_helpers.js b/app/javascript/flavours/glitch/util/react_helpers.js new file mode 100644 index 000000000..0826f3584 --- /dev/null +++ b/app/javascript/flavours/glitch/util/react_helpers.js @@ -0,0 +1,21 @@ +// This function binds the given `handlers` to the `target`. +export function assignHandlers (target, handlers) { + if (!target || !handlers) { + return; + } + + // We just bind each handler to the `target`. + const handle = target.handlers = {}; + handlers.keys().forEach( + key => handle.key = key.bind(target) + ); +} + +// This function only returns the component if the result of calling +// `test` with `data` is `true`. Useful with funciton binding. +export function conditionalRender (test, data, component) { + return test ? component : null; +} + +// This object provides props to make the component not visible. +export const hiddenComponent = { style: { display: 'none' } }; diff --git a/app/javascript/flavours/glitch/util/redux_helpers.js b/app/javascript/flavours/glitch/util/redux_helpers.js new file mode 100644 index 000000000..3bc8bc86f --- /dev/null +++ b/app/javascript/flavours/glitch/util/redux_helpers.js @@ -0,0 +1,7 @@ +// Merges react-redux props. +export function mergeProps (stateProps, dispatchProps, ownProps) { + Object.assign({}, ownProps, { + dispatch: Object.assign({}, dispatchProps, ownProps.dispatch || {}), + state: Object.assign({}, stateProps, ownProps.state || {}), + }); +} |