diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features/compose')
36 files changed, 2402 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options.js new file mode 100644 index 000000000..045bad2e5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/advanced_options.js @@ -0,0 +1,62 @@ +// 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 new file mode 100644 index 000000000..98b3b6a44 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js @@ -0,0 +1,35 @@ +// 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 new file mode 100644 index 000000000..6c7a1f55f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/attach_options.js @@ -0,0 +1,131 @@ +// 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 new file mode 100644 index 000000000..3d474af30 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js @@ -0,0 +1,24 @@ +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 new file mode 100644 index 000000000..0ecfc9141 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.js @@ -0,0 +1,25 @@ +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 new file mode 100644 index 000000000..67ce935f4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -0,0 +1,286 @@ +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 new file mode 100644 index 000000000..1b0000fb7 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -0,0 +1,77 @@ +// 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/emoji_picker_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js new file mode 100644 index 000000000..cf89f91d3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js @@ -0,0 +1,376 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { EmojiPicker as EmojiPickerAsync } from 'flavours/glitch/util/async-components'; +import Overlay from 'react-overlays/lib/Overlay'; +import classNames from 'classnames'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import detectPassiveEvents from 'detect-passive-events'; +import { buildCustomEmojis } from 'flavours/glitch/util/emoji'; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, + emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, +}); + +const assetHost = process.env.CDN_HOST || ''; +let EmojiPicker, Emoji; // load asynchronously + +const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; +const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false; + +const categoriesSort = [ + 'recent', + 'custom', + 'people', + 'nature', + 'foods', + 'activity', + 'places', + 'objects', + 'symbols', + 'flags', +]; + +class ModifierPickerMenu extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + onSelect: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + }; + + handleClick = e => { + this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.active) { + this.attachListeners(); + } else { + this.removeListeners(); + } + } + + componentWillUnmount () { + this.removeListeners(); + } + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + attachListeners () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + removeListeners () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + } + + render () { + const { active } = this.props; + + return ( + <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> + <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button> + <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button> + </div> + ); + } + +} + +class ModifierPicker extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + modifier: PropTypes.number, + onChange: PropTypes.func, + onClose: PropTypes.func, + onOpen: PropTypes.func, + }; + + handleClick = () => { + if (this.props.active) { + this.props.onClose(); + } else { + this.props.onOpen(); + } + } + + handleSelect = modifier => { + this.props.onChange(modifier); + this.props.onClose(); + } + + render () { + const { active, modifier } = this.props; + + return ( + <div className='emoji-picker-dropdown__modifiers'> + <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} /> + <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} /> + </div> + ); + } + +} + +@injectIntl +class EmojiPickerMenu extends React.PureComponent { + + static propTypes = { + custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + loading: PropTypes.bool, + onClose: PropTypes.func.isRequired, + onPick: PropTypes.func.isRequired, + style: PropTypes.object, + placement: PropTypes.string, + arrowOffsetLeft: PropTypes.string, + arrowOffsetTop: PropTypes.string, + intl: PropTypes.object.isRequired, + skinTone: PropTypes.number.isRequired, + onSkinTone: PropTypes.func.isRequired, + }; + + static defaultProps = { + style: {}, + loading: true, + placement: 'bottom', + frequentlyUsedEmojis: [], + }; + + state = { + modifierOpen: false, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + 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; + } + + getI18n = () => { + const { intl } = this.props; + + return { + search: intl.formatMessage(messages.emoji_search), + notfound: intl.formatMessage(messages.emoji_not_found), + categories: { + search: intl.formatMessage(messages.search_results), + recent: intl.formatMessage(messages.recent), + people: intl.formatMessage(messages.people), + nature: intl.formatMessage(messages.nature), + foods: intl.formatMessage(messages.food), + activity: intl.formatMessage(messages.activity), + places: intl.formatMessage(messages.travel), + objects: intl.formatMessage(messages.objects), + symbols: intl.formatMessage(messages.symbols), + flags: intl.formatMessage(messages.flags), + custom: intl.formatMessage(messages.custom), + }, + }; + } + + handleClick = emoji => { + if (!emoji.native) { + emoji.native = emoji.colons; + } + + this.props.onClose(); + this.props.onPick(emoji); + } + + handleModifierOpen = () => { + this.setState({ modifierOpen: true }); + } + + handleModifierClose = () => { + this.setState({ modifierOpen: false }); + } + + handleModifierChange = modifier => { + this.props.onSkinTone(modifier); + } + + render () { + const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props; + + if (loading) { + return <div style={{ width: 299 }} />; + } + + const title = intl.formatMessage(messages.emoji); + const { modifierOpen } = this.state; + + return ( + <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> + <EmojiPicker + perLine={8} + emojiSize={22} + sheetSize={32} + custom={buildCustomEmojis(custom_emojis)} + color='' + emoji='' + set='twitter' + title={title} + i18n={this.getI18n()} + onClick={this.handleClick} + include={categoriesSort} + recent={frequentlyUsedEmojis} + skin={skinTone} + showPreview={false} + backgroundImageFn={backgroundImageFn} + emojiTooltip + /> + + <ModifierPicker + active={modifierOpen} + modifier={skinTone} + onOpen={this.handleModifierOpen} + onClose={this.handleModifierClose} + onChange={this.handleModifierChange} + /> + </div> + ); + } + +} + +@injectIntl +export default class EmojiPickerDropdown extends React.PureComponent { + + static propTypes = { + custom_emojis: ImmutablePropTypes.list, + frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string), + intl: PropTypes.object.isRequired, + onPickEmoji: PropTypes.func.isRequired, + onSkinTone: PropTypes.func.isRequired, + skinTone: PropTypes.number.isRequired, + }; + + state = { + active: false, + loading: false, + }; + + setRef = (c) => { + this.dropdown = c; + } + + onShowDropdown = () => { + this.setState({ active: true }); + + if (!EmojiPicker) { + this.setState({ loading: true }); + + EmojiPickerAsync().then(EmojiMart => { + EmojiPicker = EmojiMart.Picker; + Emoji = EmojiMart.Emoji; + + this.setState({ loading: false }); + }).catch(() => { + this.setState({ loading: false }); + }); + } + } + + onHideDropdown = () => { + this.setState({ active: false }); + } + + onToggle = (e) => { + if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (this.state.active) { + this.onHideDropdown(); + } else { + this.onShowDropdown(); + } + } + } + + handleKeyDown = e => { + if (e.key === 'Escape') { + this.onHideDropdown(); + } + } + + setTargetRef = c => { + this.target = c; + } + + findTarget = () => { + return this.target; + } + + render () { + const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; + const title = intl.formatMessage(messages.emoji); + const { active, loading } = this.state; + + return ( + <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> + <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> + <img + className={classNames('emojione', { 'pulse-loading': active && loading })} + alt='🙂' + src={`${assetHost}/emoji/1f602.svg`} + /> + </div> + + <Overlay show={active} placement='bottom' target={this.findTarget}> + <EmojiPickerMenu + custom_emojis={this.props.custom_emojis} + loading={loading} + onClose={this.onHideDropdown} + onPick={onPickEmoji} + onSkinTone={onSkinTone} + skinTone={skinTone} + frequentlyUsedEmojis={frequentlyUsedEmojis} + /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js new file mode 100644 index 000000000..1b6d74123 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'flavours/glitch/components/avatar'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Permalink from 'flavours/glitch/components/permalink'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class NavigationBar extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + }; + + render () { + return ( + <div className='navigation-bar'> + <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span> + <Avatar account={this.props.account} size={40} /> + </Permalink> + + <div className='navigation-bar__profile'> + <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong> + </Permalink> + + <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> + </div> + + <IconButton title='' icon='close' onClick={this.props.onClose} /> + </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 new file mode 100644 index 000000000..90f062f8f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js @@ -0,0 +1,200 @@ +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 new file mode 100644 index 000000000..cb28e51be --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js @@ -0,0 +1,63 @@ +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'; + +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') }; + + 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' dangerouslySetInnerHTML={content} /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js new file mode 100644 index 000000000..1ce66b19d --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search.js @@ -0,0 +1,129 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import Overlay from 'react-overlays/lib/Overlay'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +const messages = defineMessages({ + placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, +}); + +class SearchPopout extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + }; + + render () { + const { style } = this.props; + + return ( + <div style={{ ...style, position: 'absolute', width: 285 }}> + <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='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4> + + <ul> + <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li> + <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> + <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li> + <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li> + </ul> + + <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' /> + </div> + )} + </Motion> + </div> + ); + } + +} + +@injectIntl +export default class Search extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + submitted: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onShow: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + expanded: false, + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + } + + handleClear = (e) => { + e.preventDefault(); + + if (this.props.value.length > 0 || this.props.submitted) { + this.props.onClear(); + } + } + + handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.props.onSubmit(); + } else if (e.key === 'Escape') { + document.querySelector('.ui').parentElement.focus(); + } + } + + noop () { + + } + + handleFocus = () => { + this.setState({ expanded: true }); + this.props.onShow(); + } + + handleBlur = () => { + this.setState({ expanded: false }); + } + + render () { + const { intl, value, submitted } = this.props; + const { expanded } = this.state; + const hasValue = value.length > 0 || submitted; + + return ( + <div className='search'> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span> + <input + className='search__input' + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyDown} + onFocus={this.handleFocus} + onBlur={this.handleBlur} + /> + </label> + + <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> + <i className={`fa fa-search ${hasValue ? '' : 'active'}`} /> + <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} /> + </div> + + <Overlay show={expanded && !hasValue} placement='bottom' target={this}> + <SearchPopout /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js new file mode 100644 index 000000000..2a4818d4e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js @@ -0,0 +1,65 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage } from 'react-intl'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import StatusContainer from 'flavours/glitch/containers/status_container'; +import { Link } from 'react-router-dom'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +export default class SearchResults extends ImmutablePureComponent { + + static propTypes = { + results: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { results } = this.props; + + let accounts, statuses, hashtags; + let count = 0; + + if (results.get('accounts') && results.get('accounts').size > 0) { + count += results.get('accounts').size; + accounts = ( + <div className='search-results__section'> + {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} + </div> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <div className='search-results__section'> + {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} + </div> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <div className='search-results__section'> + {results.get('hashtags').map(hashtag => + <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}> + #{hashtag} + </Link> + )} + </div> + ); + } + + return ( + <div className='search-results'> + <div className='search-results__header'> + <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> + </div> + + {accounts} + {statuses} + {hashtags} + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js new file mode 100644 index 000000000..9c8ffab1f --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default class TextIconButton extends React.PureComponent { + + static propTypes = { + label: PropTypes.string.isRequired, + title: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func.isRequired, + ariaControls: PropTypes.string, + }; + + handleClick = (e) => { + e.preventDefault(); + this.props.onClick(); + } + + render () { + const { label, title, active, ariaControls } = this.props; + + return ( + <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}> + {label} + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js new file mode 100644 index 000000000..a1fc93234 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -0,0 +1,96 @@ +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 new file mode 100644 index 000000000..f06167a2a --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_button.js @@ -0,0 +1,77 @@ +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 new file mode 100644 index 000000000..b7f112205 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js @@ -0,0 +1,29 @@ +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 new file mode 100644 index 000000000..2a3b8ceb4 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js @@ -0,0 +1,42 @@ +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 new file mode 100644 index 000000000..4962e76c8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/warning.js @@ -0,0 +1,26 @@ +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 new file mode 100644 index 000000000..da381568b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js @@ -0,0 +1,20 @@ +// 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 new file mode 100644 index 000000000..0e1c328fe --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js @@ -0,0 +1,15 @@ +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 new file mode 100644 index 000000000..e2e93e44b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -0,0 +1,71 @@ +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 new file mode 100644 index 000000000..ba85edd87 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js @@ -0,0 +1,82 @@ +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/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js new file mode 100644 index 000000000..eb630ffbb --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux'; +import NavigationBar from '../components/navigation_bar'; +import { me } from 'flavours/glitch/util/initial_state'; + +const mapStateToProps = state => { + return { + account: state.getIn(['accounts', me]), + }; +}; + +export default connect(mapStateToProps)(NavigationBar); 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 new file mode 100644 index 000000000..cb94fcc80 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js @@ -0,0 +1,24 @@ +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 new file mode 100644 index 000000000..a7c82d135 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,24 @@ +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/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js new file mode 100644 index 000000000..8f4bfcf08 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js @@ -0,0 +1,35 @@ +import { connect } from 'react-redux'; +import { + changeSearch, + clearSearch, + submitSearch, + showSearch, +} from 'flavours/glitch/actions/search'; +import Search from '../components/search'; + +const mapStateToProps = state => ({ + value: state.getIn(['search', 'value']), + submitted: state.getIn(['search', 'submitted']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeSearch(value)); + }, + + onClear () { + dispatch(clearSearch()); + }, + + onSubmit () { + dispatch(submitSearch()); + }, + + onShow () { + dispatch(showSearch()); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js new file mode 100644 index 000000000..16d95d417 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']), +}); + +export default connect(mapStateToProps)(SearchResults); 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 new file mode 100644 index 000000000..cf6706c0e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,71 @@ +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 new file mode 100644 index 000000000..d7b4246bc --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js @@ -0,0 +1,25 @@ +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 new file mode 100644 index 000000000..4c1cb49e9 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..368038425 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..a6798bf51 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js @@ -0,0 +1,8 @@ +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 new file mode 100644 index 000000000..0cfee96da --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js @@ -0,0 +1,9 @@ +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 new file mode 100644 index 000000000..f20c75b91 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js @@ -0,0 +1,24 @@ +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 new file mode 100644 index 000000000..63c9500b1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/index.js @@ -0,0 +1,126 @@ +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> + ); + } + +} |