diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features/compose')
34 files changed, 3013 insertions, 0 deletions
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..fb9bb5035 --- /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='account small' title={account.get('acct')}> + <div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div> + <DisplayName account={account} inline /> + </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..a7cb95222 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -0,0 +1,366 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import ReplyIndicatorContainer from '../containers/reply_indicator_container'; +import AutosuggestTextarea from '../../../components/autosuggest_textarea'; +import AutosuggestInput from '../../../components/autosuggest_input'; +import { defineMessages, injectIntl } from 'react-intl'; +import EmojiPicker from 'flavours/glitch/features/emoji_picker'; +import PollFormContainer from '../containers/poll_form_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 { countableText } from 'flavours/glitch/util/counter'; +import OptionsContainer from '../containers/options_container'; +import Publisher from './publisher'; +import TextareaIcons from './textarea_icons'; +import { maxChars } from 'flavours/glitch/util/initial_state'; +import CharacterCounter from './character_counter'; + +const messages = defineMessages({ + placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, + missingDescriptionMessage: { id: 'confirmations.missing_media_description.message', + defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' }, + missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm', + defaultMessage: 'Send anyway' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, +}); + +export default @injectIntl +class ComposeForm extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + intl: PropTypes.object.isRequired, + text: PropTypes.string, + suggestions: ImmutablePropTypes.list, + spoiler: PropTypes.bool, + privacy: PropTypes.string, + spoilerText: PropTypes.string, + focusDate: PropTypes.instanceOf(Date), + caretPosition: PropTypes.number, + preselectDate: PropTypes.instanceOf(Date), + isSubmitting: PropTypes.bool, + isChangingUpload: PropTypes.bool, + isUploading: PropTypes.bool, + onChange: PropTypes.func, + onSubmit: PropTypes.func, + onClearSuggestions: PropTypes.func, + onFetchSuggestions: PropTypes.func, + onSuggestionSelected: PropTypes.func, + onChangeSpoilerText: PropTypes.func, + onPaste: PropTypes.func, + onPickEmoji: PropTypes.func, + showSearch: PropTypes.bool, + anyMedia: PropTypes.bool, + singleColumn: PropTypes.bool, + + advancedOptions: ImmutablePropTypes.map, + layout: PropTypes.string, + media: ImmutablePropTypes.list, + sideArm: PropTypes.string, + sensitive: PropTypes.bool, + spoilersAlwaysOn: PropTypes.bool, + mediaDescriptionConfirmation: PropTypes.bool, + preselectOnReply: PropTypes.bool, + onChangeSpoilerness: PropTypes.func, + onChangeVisibility: PropTypes.func, + onPaste: PropTypes.func, + onMediaDescriptionConfirm: PropTypes.func, + }; + + static defaultProps = { + showSearch: false, + }; + + handleChange = (e) => { + this.props.onChange(e.target.value); + } + + handleSubmit = (overriddenVisibility = null) => { + const { textarea: { value }, uploadForm } = this; + const { + onChange, + onSubmit, + isSubmitting, + isChangingUpload, + isUploading, + media, + anyMedia, + text, + mediaDescriptionConfirmation, + onMediaDescriptionConfirm, + onChangeVisibility, + } = this.props; + + // If something changes inside the textarea, then we update the + // state before submitting. + if (onChange && text !== value) { + onChange(value); + } + + // Submit disabled: + if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) { + return; + } + + // Submit unless there are media with missing descriptions + if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) { + const firstWithoutDescription = media.find(item => !item.get('description')); + onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'), overriddenVisibility); + } else if (onSubmit) { + if (onChangeVisibility && overriddenVisibility) { + onChangeVisibility(overriddenVisibility); + } + onSubmit(this.context.router ? this.context.router.history : null); + } + } + + // Changes the text value of the spoiler. + handleChangeSpoiler = ({ target: { value } }) => { + const { onChangeSpoilerText } = this.props; + if (onChangeSpoilerText) { + onChangeSpoilerText(value); + } + } + + setRef = c => { + this.composeForm = c; + }; + + // Inserts an emoji at the caret. + handleEmoji = (data) => { + const { textarea: { selectionStart } } = this; + const { onPickEmoji } = this.props; + if (onPickEmoji) { + onPickEmoji(selectionStart, data); + } + } + + // Handles the secondary submit button. + handleSecondarySubmit = () => { + const { + sideArm, + } = this.props; + this.handleSubmit(sideArm === 'none' ? null : sideArm); + } + + // Selects a suggestion from the autofill. + onSuggestionSelected = (tokenStart, token, value) => { + this.props.onSuggestionSelected(tokenStart, token, value, ['text']); + } + + onSpoilerSuggestionSelected = (tokenStart, token, value) => { + this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']); + } + + handleKeyDown = (e) => { + if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + this.handleSubmit(); + } + + if (e.keyCode == 13 && e.altKey) { + this.handleSecondarySubmit(); + } + } + + // Sets a reference to the textarea. + setAutosuggestTextarea = (textareaComponent) => { + if (textareaComponent) { + this.textarea = textareaComponent.textarea; + } + } + + // Sets a reference to the CW field. + handleRefSpoilerText = (spoilerComponent) => { + if (spoilerComponent) { + this.spoilerText = spoilerComponent.input; + } + } + + handleFocus = () => { + if (this.composeForm && !this.props.singleColumn) { + const { left, right } = this.composeForm.getBoundingClientRect(); + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { + this.composeForm.scrollIntoView(); + } + } + } + + // 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. + componentDidUpdate (prevProps) { + const { + textarea, + spoilerText, + } = this; + const { + focusDate, + caretPosition, + isSubmitting, + preselectDate, + text, + preselectOnReply, + singleColumn, + } = this.props; + let selectionEnd, selectionStart; + + // Caret/selection handling. + if (focusDate !== prevProps.focusDate) { + switch (true) { + case preselectDate !== prevProps.preselectDate && preselectOnReply: + selectionStart = text.search(/\s/) + 1; + selectionEnd = text.length; + break; + case !isNaN(caretPosition) && caretPosition !== null: + selectionStart = selectionEnd = caretPosition; + break; + default: + selectionStart = selectionEnd = text.length; + } + if (textarea) { + textarea.setSelectionRange(selectionStart, selectionEnd); + textarea.focus(); + if (!singleColumn) textarea.scrollIntoView(); + } + + // Refocuses the textarea after submitting. + } else if (textarea && prevProps.isSubmitting && !isSubmitting) { + textarea.focus(); + } else if (this.props.spoiler !== prevProps.spoiler) { + if (this.props.spoiler) { + if (spoilerText) { + spoilerText.focus(); + } + } else { + if (textarea) { + textarea.focus(); + } + } + } + } + + + render () { + const { + handleEmoji, + handleSecondarySubmit, + handleSelect, + handleSubmit, + handleRefTextarea, + } = this; + const { + advancedOptions, + anyMedia, + intl, + isSubmitting, + isChangingUpload, + isUploading, + layout, + media, + onChangeSpoilerness, + onChangeVisibility, + onClearSuggestions, + onFetchSuggestions, + onPaste, + privacy, + sensitive, + showSearch, + sideArm, + spoiler, + spoilerText, + suggestions, + text, + spoilersAlwaysOn, + } = this.props; + + let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia); + + const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`; + + return ( + <div className='composer'> + <WarningContainer /> + + <ReplyIndicatorContainer /> + + <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}> + <AutosuggestInput + placeholder={intl.formatMessage(messages.spoiler_placeholder)} + value={spoilerText} + onChange={this.handleChangeSpoiler} + onKeyDown={this.handleKeyDown} + disabled={!spoiler} + ref={this.handleRefSpoilerText} + suggestions={this.props.suggestions} + onSuggestionsFetchRequested={onFetchSuggestions} + onSuggestionsClearRequested={onClearSuggestions} + onSuggestionSelected={this.onSpoilerSuggestionSelected} + searchTokens={[':']} + id='glitch.composer.spoiler.input' + className='spoiler-input__input' + autoFocus={false} + /> + </div> + + <AutosuggestTextarea + ref={this.setAutosuggestTextarea} + placeholder={intl.formatMessage(messages.placeholder)} + disabled={isSubmitting} + value={this.props.text} + onChange={this.handleChange} + onKeyDown={this.handleKeyDown} + suggestions={this.props.suggestions} + onFocus={this.handleFocus} + onSuggestionsFetchRequested={onFetchSuggestions} + onSuggestionsClearRequested={onClearSuggestions} + onSuggestionSelected={this.onSuggestionSelected} + onPaste={onPaste} + autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} + > + <EmojiPicker onPickEmoji={handleEmoji} /> + <TextareaIcons advancedOptions={advancedOptions} /> + <div className='compose-form__modifiers'> + <UploadFormContainer /> + <PollFormContainer /> + </div> + </AutosuggestTextarea> + + <div className='composer--options-wrapper'> + <OptionsContainer + advancedOptions={advancedOptions} + disabled={isSubmitting} + onChangeVisibility={onChangeVisibility} + onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness} + onUpload={onPaste} + privacy={privacy} + sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)} + spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} + /> + <div className='compose--counter-wrapper'> + <CharacterCounter text={countText} max={maxChars} /> + </div> + </div> + + <Publisher + countText={countText} + disabled={disabledButton} + onSecondarySubmit={handleSecondarySubmit} + onSubmit={handleSubmit} + privacy={privacy} + sideArm={sideArm} + /> + </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..60035b705 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -0,0 +1,235 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Overlay from 'react-overlays/lib/Overlay'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import DropdownMenu from './dropdown_menu'; + +// Utils. +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { + + static 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, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func, + }; + + state = { + needsModalUpdate: false, + open: false, + openedViaKeyboard: undefined, + placement: 'bottom', + }; + + // Toggles opening and closing the dropdown. + handleToggle = ({ target, type }) => { + const { onModalOpen } = this.props; + const { open } = this.state; + + if (isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + const modal = this.handleMakeModal(); + if (modal && onModalOpen) { + onModalOpen(modal); + } + } + } else { + const { top } = target.getBoundingClientRect(); + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' }); + } + } + + handleKeyDown = (e) => { + switch (e.key) { + case 'Escape': + this.handleClose(); + break; + } + } + + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleToggle(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + } + + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } + this.setState({ open: false }); + } + + // Creates an action modal object. + handleMakeModal = () => { + const component = this; + const { + items, + onChange, + onModalOpen, + onModalClose, + value, + } = this.props; + + // Required props. + if (!(onChange && onModalOpen && onModalClose && items)) { + return null; + } + + // The object. + return { + actions: items.map( + ({ + name, + ...rest + }) => ({ + ...rest, + active: value && name === value, + name, + onClick (e) { + e.preventDefault(); // Prevents focus from changing + onModalClose(); + onChange(name); + }, + onPassiveClick (e) { + e.preventDefault(); // Prevents focus from changing + onChange(name); + component.setState({ needsModalUpdate: true }); + }, + }) + ), + }; + } + + // If our modal is open and our props update, we need to also update + // the modal. + handleUpdate = () => { + const { onModalOpen } = this.props; + const { needsModalUpdate } = this.state; + + // Gets our modal object. + const modal = this.handleMakeModal(); + + // Reopens the modal with the new object. + if (needsModalUpdate && modal && onModalOpen) { + onModalOpen(modal); + } + } + + // Updates our modal as necessary. + componentDidUpdate (prevProps) { + const { items } = this.props; + const { needsModalUpdate } = this.state; + if (needsModalUpdate && items.find( + (item, i) => item.on !== prevProps.items[i].on + )) { + this.handleUpdate(); + this.setState({ needsModalUpdate: false }); + } + } + + // Rendering. + render () { + const { + active, + disabled, + title, + icon, + items, + onChange, + value, + } = this.props; + const { open, placement } = this.state; + const computedClass = classNames('composer--options--dropdown', { + active, + open, + top: placement === 'top', + }); + + // The result. + return ( + <div + className={computedClass} + onKeyDown={this.handleKeyDown} + > + <IconButton + active={open || active} + className='value' + disabled={disabled} + icon={icon} + inverted + onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} + size={18} + style={{ + height: null, + lineHeight: '27px', + }} + title={title} + /> + <Overlay + containerPadding={20} + placement={placement} + show={open} + target={this} + > + <DropdownMenu + items={items} + onChange={onChange} + onClose={this.handleClose} + value={value} + openedViaKeyboard={this.state.openedViaKeyboard} + /> + </Overlay> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js new file mode 100644 index 000000000..bee06e64c --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -0,0 +1,239 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import spring from 'react-motion/lib/spring'; +import Toggle from 'react-toggle'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import classNames from 'classnames'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { withPassive } from 'flavours/glitch/util/dom_helpers'; +import Motion from 'flavours/glitch/util/optional_motion'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// The spring to use with our motion. +const springMotion = spring(1, { + damping: 35, + stiffness: 400, +}); + +// The component. +export default class ComposerOptionsDropdownContent extends React.PureComponent { + + static propTypes = { + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })), + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + style: PropTypes.object, + value: PropTypes.string, + openedViaKeyboard: PropTypes.bool, + }; + + static defaultProps = { + style: {}, + }; + + state = { + mounted: false, + value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined, + }; + + // When the document is clicked elsewhere, we close the dropdown. + handleDocumentClick = (e) => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + } + } + + // Stores our node in `this.node`. + handleRef = (node) => { + this.node = node; + } + + // On mounting, we add our listeners. + componentDidMount () { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('touchend', this.handleDocumentClick, withPassive); + if (this.focusedItem) { + this.focusedItem.focus({ preventScroll: true }); + } else { + this.node.firstChild.focus({ preventScroll: true }); + } + this.setState({ mounted: true }); + } + + // On unmounting, we remove our listeners. + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, false); + document.removeEventListener('touchend', this.handleDocumentClick, withPassive); + } + + handleClick = (name, e) => { + const { + onChange, + onClose, + items, + } = this.props; + + const { on } = this.props.items.find(item => item.name === name); + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined')) { + onClose(); + } + onChange(name); + } + + // Handle changes differently whether the dropdown is a list of options or actions + handleChange = (name) => { + if (this.props.value) { + this.props.onChange(name); + } else { + this.setState({ value: name }); + } + } + + handleKeyDown = (name, e) => { + const { items } = this.props; + const index = items.findIndex(item => { + return (item.name === name); + }); + let element = null; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + case ' ': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1] || this.node.firstChild; + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1] || this.node.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = this.node.childNodes[index - 1] || this.node.lastChild; + } else { + element = this.node.childNodes[index + 1] || this.node.firstChild; + } + break; + case 'Home': + element = this.node.firstChild; + break; + case 'End': + element = this.node.lastChild; + break; + } + + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + } + + setFocusRef = c => { + this.focusedItem = c; + } + + renderItem = (item) => { + const { name, icon, meta, on, text } = item; + + const active = (name === (this.props.value || this.state.value)); + + const computedClass = classNames('composer--options--dropdown--content--item', { + active, + lengthy: meta, + 'toggled-off': !on && on !== null && typeof on !== 'undefined', + 'toggled-on': on, + 'with-icon': icon, + }); + + let prefix = null; + + if (on !== null && typeof on !== 'undefined') { + prefix = <Toggle checked={on} onChange={this.handleClick.bind(this, name)} />; + } else if (icon) { + prefix = <Icon className='icon' fixedWidth id={icon} /> + } + + return ( + <div + className={computedClass} + onClick={this.handleClick.bind(this, name)} + onKeyDown={this.handleKeyDown.bind(this, name)} + role='option' + tabIndex='0' + key={name} + data-index={name} + ref={active ? this.setFocusRef : null} + > + {prefix} + + <div className='content'> + <strong>{text}</strong> + {meta} + </div> + </div> + ); + } + + // Rendering. + render () { + const { mounted } = this.state; + const { + items, + onChange, + onClose, + style, + } = this.props; + + // The result. + return ( + <Motion + defaultStyle={{ + opacity: 0, + scaleX: 0.85, + scaleY: 0.75, + }} + style={{ + opacity: springMotion, + scaleX: springMotion, + scaleY: springMotion, + }} + > + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays + <div + className='composer--options--dropdown--content' + ref={this.handleRef} + role='listbox' + style={{ + ...style, + opacity: opacity, + transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, + }} + > + {!!items && items.map(item => this.renderItem(item))} + </div> + )} + </Motion> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js new file mode 100644 index 000000000..5b456b717 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/header.js @@ -0,0 +1,134 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; +import { Link } from 'react-router-dom'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { conditionalRender } from 'flavours/glitch/util/react_helpers'; +import { signOutLink } from 'flavours/glitch/util/backend_links'; + +// 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', + }, +}); + +export default @injectIntl +class Header extends ImmutablePureComponent { + static propTypes = { + columns: ImmutablePropTypes.list, + unreadNotifications: PropTypes.number, + showNotificationsBadge: PropTypes.bool, + intl: PropTypes.object, + onSettingsClick: PropTypes.func, + onLogout: PropTypes.func.isRequired, + }; + + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); + + return false; + } + + render () { + const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props; + + // Only renders the component if the column isn't being shown. + const renderForColumn = conditionalRender.bind(null, + columnId => !columns || !columns.some( + column => column.get('id') === columnId + ) + ); + + // The result. + return ( + <nav className='drawer--header'> + <Link + aria-label={intl.formatMessage(messages.start)} + title={intl.formatMessage(messages.start)} + to='/getting-started' + ><Icon id='asterisk' /></Link> + {renderForColumn('HOME', ( + <Link + aria-label={intl.formatMessage(messages.home_timeline)} + title={intl.formatMessage(messages.home_timeline)} + to='/timelines/home' + ><Icon id='home' /></Link> + ))} + {renderForColumn('NOTIFICATIONS', ( + <Link + aria-label={intl.formatMessage(messages.notifications)} + title={intl.formatMessage(messages.notifications)} + to='/notifications' + > + <span className='icon-badge-wrapper'> + <Icon id='bell' /> + { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />} + </span> + </Link> + ))} + {renderForColumn('COMMUNITY', ( + <Link + aria-label={intl.formatMessage(messages.community)} + title={intl.formatMessage(messages.community)} + to='/timelines/public/local' + ><Icon id='users' /></Link> + ))} + {renderForColumn('PUBLIC', ( + <Link + aria-label={intl.formatMessage(messages.public)} + title={intl.formatMessage(messages.public)} + to='/timelines/public' + ><Icon id='globe' /></Link> + ))} + <a + aria-label={intl.formatMessage(messages.settings)} + onClick={onSettingsClick} + href='#' + title={intl.formatMessage(messages.settings)} + ><Icon id='cogs' /></a> + <a + aria-label={intl.formatMessage(messages.logout)} + onClick={this.handleLogoutClick} + href={ signOutLink } + title={intl.formatMessage(messages.logout)} + ><Icon id='sign-out' /></a> + </nav> + ); + }; +} 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..f6bfbdd1e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js @@ -0,0 +1,38 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from 'flavours/glitch/components/avatar'; +import Permalink from 'flavours/glitch/components/permalink'; +import { FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { profileLink } from 'flavours/glitch/util/backend_links'; + +export default class NavigationBar extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + return ( + <div className='drawer--account'> + <Permalink className='avatar' 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={48} /> + </Permalink> + + <div className='navigation-bar__profile'> + <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> + <strong>@{this.props.account.get('acct')}</strong> + </Permalink> + + { profileLink !== undefined && ( + <a + className='edit' + href={ profileLink } + ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a> + )} + </div> + </div> + ); + }; +} diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js new file mode 100644 index 000000000..9e332aabd --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -0,0 +1,367 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import TextIconButton from './text_icon_button'; +import Dropdown from './dropdown'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; +import { pollLimits } from 'flavours/glitch/util/initial_state'; + +// 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', + }, + content_type: { + defaultMessage: 'Content type', + id: 'content-type.change', + }, + direct_long: { + defaultMessage: 'Visible for mentioned users only', + id: 'privacy.direct.long', + }, + direct_short: { + defaultMessage: 'Direct', + id: 'privacy.direct.short', + }, + doodle: { + defaultMessage: 'Draw something', + id: 'compose.attach.doodle', + }, + html: { + defaultMessage: 'HTML', + id: 'compose.content-type.html', + }, + 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', + }, + markdown: { + defaultMessage: 'Markdown', + id: 'compose.content-type.markdown', + }, + plain: { + defaultMessage: 'Plain text', + id: 'compose.content-type.plain', + }, + private_long: { + defaultMessage: 'Visible for followers only', + id: 'privacy.private.long', + }, + private_short: { + defaultMessage: 'Followers-only', + id: 'privacy.private.short', + }, + public_long: { + defaultMessage: 'Visible for all, shown in public timelines', + id: 'privacy.public.long', + }, + public_short: { + defaultMessage: 'Public', + id: 'privacy.public.short', + }, + spoiler: { + defaultMessage: 'Hide text behind warning', + id: 'compose_form.spoiler', + }, + threaded_mode_long: { + defaultMessage: 'Automatically opens a reply on posting', + id: 'advanced_options.threaded_mode.long', + }, + threaded_mode_short: { + defaultMessage: 'Threaded mode', + id: 'advanced_options.threaded_mode.short', + }, + unlisted_long: { + defaultMessage: 'Visible for all, but not in public timelines', + id: 'privacy.unlisted.long', + }, + unlisted_short: { + defaultMessage: 'Unlisted', + id: 'privacy.unlisted.short', + }, + upload: { + defaultMessage: 'Upload a file', + id: 'compose.attach.upload', + }, + add_poll: { + defaultMessage: 'Add a poll', + id: 'poll_button.add_poll', + }, + remove_poll: { + defaultMessage: 'Remove poll', + id: 'poll_button.remove_poll', + }, +}); + +export default @injectIntl +class ComposerOptions extends ImmutablePureComponent { + + static propTypes = { + acceptContentTypes: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, + disabled: PropTypes.bool, + allowMedia: PropTypes.bool, + hasMedia: PropTypes.bool, + allowPoll: PropTypes.bool, + hasPoll: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChangeAdvancedOption: PropTypes.func, + onChangeVisibility: PropTypes.func, + onChangeContentType: PropTypes.func, + onTogglePoll: PropTypes.func, + onDoodleOpen: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + onToggleSpoiler: PropTypes.func, + onUpload: PropTypes.func, + privacy: PropTypes.string, + contentType: PropTypes.string, + resetFileKey: PropTypes.number, + spoiler: PropTypes.bool, + showContentTypeChoice: PropTypes.bool, + }; + + // Handles file selection. + handleChangeFiles = ({ target: { files } }) => { + const { onUpload } = this.props; + if (files.length && onUpload) { + onUpload(files); + } + } + + // Handles attachment clicks. + handleClickAttach = (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. + handleRefFileElement = (fileElement) => { + this.fileElement = fileElement; + } + + // Rendering. + render () { + const { + acceptContentTypes, + advancedOptions, + contentType, + disabled, + allowMedia, + hasMedia, + allowPoll, + hasPoll, + intl, + onChangeAdvancedOption, + onChangeContentType, + onChangeVisibility, + onTogglePoll, + onModalClose, + onModalOpen, + onToggleSpoiler, + privacy, + resetFileKey, + spoiler, + showContentTypeChoice, + } = 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', + meta: <FormattedMessage {...messages.unlisted_long} />, + name: 'unlisted', + text: <FormattedMessage {...messages.unlisted_short} />, + }, + }; + + const contentTypeItems = { + plain: { + icon: 'file-text', + name: 'text/plain', + text: <FormattedMessage {...messages.plain} />, + }, + html: { + icon: 'code', + name: 'text/html', + text: <FormattedMessage {...messages.html} />, + }, + markdown: { + icon: 'arrow-circle-down', + name: 'text/markdown', + text: <FormattedMessage {...messages.markdown} />, + }, + }; + + // The result. + return ( + <div className='composer--options'> + <input + accept={acceptContentTypes} + disabled={disabled || !allowMedia} + key={resetFileKey} + onChange={this.handleChangeFiles} + ref={this.handleRefFileElement} + type='file' + multiple + style={{ display: 'none' }} + /> + <Dropdown + disabled={disabled || !allowMedia} + icon='paperclip' + items={[ + { + icon: 'cloud-upload', + name: 'upload', + text: <FormattedMessage {...messages.upload} />, + }, + { + icon: 'paint-brush', + name: 'doodle', + text: <FormattedMessage {...messages.doodle} />, + }, + ]} + onChange={this.handleClickAttach} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.attach)} + /> + {!!pollLimits && ( + <IconButton + active={hasPoll} + disabled={disabled || !allowPoll} + icon='tasks' + inverted + onClick={onTogglePoll} + size={18} + style={{ + height: null, + lineHeight: null, + }} + title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)} + /> + )} + <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} + /> + {showContentTypeChoice && ( + <Dropdown + disabled={disabled} + icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon} + items={[ + contentTypeItems.plain, + contentTypeItems.html, + contentTypeItems.markdown, + ]} + onChange={onChangeContentType} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.content_type)} + value={contentType} + /> + )} + {onToggleSpoiler && ( + <TextIconButton + active={spoiler} + ariaControls='glitch.composer.spoiler.input' + label='CW' + onClick={onToggleSpoiler} + title={intl.formatMessage(messages.spoiler)} + /> + )} + <Dropdown + active={advancedOptions && advancedOptions.some(value => !!value)} + disabled={disabled} + icon='ellipsis-h' + items={advancedOptions ? [ + { + meta: <FormattedMessage {...messages.local_only_long} />, + name: 'do_not_federate', + on: advancedOptions.get('do_not_federate'), + text: <FormattedMessage {...messages.local_only_short} />, + }, + { + meta: <FormattedMessage {...messages.threaded_mode_long} />, + name: 'threaded_mode', + on: advancedOptions.get('threaded_mode'), + text: <FormattedMessage {...messages.threaded_mode_short} />, + }, + ] : null} + onChange={onChangeAdvancedOption} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.advanced_options_icon_title)} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js new file mode 100644 index 000000000..e4b5104f3 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js @@ -0,0 +1,165 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Icon from 'flavours/glitch/components/icon'; +import AutosuggestInput from 'flavours/glitch/components/autosuggest_input'; +import classNames from 'classnames'; +import { pollLimits } from 'flavours/glitch/util/initial_state'; + +const messages = defineMessages({ + option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' }, + add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' }, + remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' }, + poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' }, + single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' }, + multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' }, + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, +}); + +@injectIntl +class Option extends React.PureComponent { + + static propTypes = { + title: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, + isPollMultiple: PropTypes.bool, + autoFocus: PropTypes.bool, + onChange: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleOptionTitleChange = e => { + this.props.onChange(this.props.index, e.target.value); + }; + + handleOptionRemove = () => { + this.props.onRemove(this.props.index); + }; + + onSuggestionsClearRequested = () => { + this.props.onClearSuggestions(); + } + + onSuggestionsFetchRequested = (token) => { + this.props.onFetchSuggestions(token); + } + + onSuggestionSelected = (tokenStart, token, value) => { + this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]); + } + + render () { + const { isPollMultiple, title, index, autoFocus, intl } = this.props; + + return ( + <li> + <label className='poll__option editable'> + <span className={classNames('poll__input', { checkbox: isPollMultiple })} /> + + <AutosuggestInput + placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })} + maxLength={pollLimits.max_option_chars} + value={title} + onChange={this.handleOptionTitleChange} + suggestions={this.props.suggestions} + onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} + onSuggestionsClearRequested={this.onSuggestionsClearRequested} + onSuggestionSelected={this.onSuggestionSelected} + searchTokens={[':']} + autoFocus={autoFocus} + /> + </label> + + <div className='poll__cancel'> + <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} /> + </div> + </li> + ); + } + +} + +export default +@injectIntl +class PollForm extends ImmutablePureComponent { + + static propTypes = { + options: ImmutablePropTypes.list, + expiresIn: PropTypes.number, + isMultiple: PropTypes.bool, + onChangeOption: PropTypes.func.isRequired, + onAddOption: PropTypes.func.isRequired, + onRemoveOption: PropTypes.func.isRequired, + onChangeSettings: PropTypes.func.isRequired, + suggestions: ImmutablePropTypes.list, + onClearSuggestions: PropTypes.func.isRequired, + onFetchSuggestions: PropTypes.func.isRequired, + onSuggestionSelected: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleAddOption = () => { + this.props.onAddOption(''); + }; + + handleSelectDuration = e => { + this.props.onChangeSettings(e.target.value, this.props.isMultiple); + }; + + handleSelectMultiple = e => { + this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true'); + }; + + render () { + const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props; + + if (!options) { + return null; + } + + const autoFocusIndex = options.indexOf(''); + + return ( + <div className='compose-form__poll-wrapper'> + <ul> + {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)} + {options.size < pollLimits.max_options && ( + <label className='poll__text editable'> + <span className={classNames('poll__input')} style={{ opacity: 0 }} /> + <button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button> + </label> + )} + </ul> + + <div className='poll__footer'> + <select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}> + <option value='false'>{intl.formatMessage(messages.single_choice)}</option> + <option value='true'>{intl.formatMessage(messages.multiple_choices)}</option> + </select> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={expiresIn} onChange={this.handleSelectDuration}> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js new file mode 100644 index 000000000..97890f40d --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js @@ -0,0 +1,118 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import { length } from 'stringz'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// 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', + }, +}); + +export default @injectIntl +class Publisher extends ImmutablePureComponent { + + static 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']), + }; + + handleSubmit = () => { + this.props.onSubmit(); + }; + + render () { + const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props; + + const diff = maxChars - length(countText || ''); + const computedClass = classNames('composer--publisher', { + disabled: disabled || diff < 0, + over: diff < 0, + }); + + return ( + <div className={computedClass}> + {sideArm && sideArm !== 'none' ? ( + <Button + className='side_arm' + disabled={disabled || diff < 0} + onClick={onSecondarySubmit} + style={{ padding: null }} + text={ + <span> + <Icon + id={{ + public: 'globe', + unlisted: 'unlock', + private: 'lock', + direct: 'envelope', + }[sideArm]} + /> + </span> + } + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`} + /> + ) : null} + <Button + className='primary' + text={function () { + switch (true) { + case !!sideArm && sideArm !== 'none': + case privacy === 'direct': + case privacy === 'private': + return ( + <span> + <Icon + id={{ + direct: 'envelope', + private: 'lock', + public: 'globe', + unlisted: 'unlock', + }[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={this.handleSubmit} + disabled={disabled || diff < 0} + /> + </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..9d5b65a40 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js @@ -0,0 +1,86 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Components. +import AccountContainer from 'flavours/glitch/containers/account_container'; +import IconButton from 'flavours/glitch/components/icon_button'; +import AttachmentList from 'flavours/glitch/components/attachment_list'; + +// Utils. +import { isRtl } from 'flavours/glitch/util/rtl'; + +// Messages. +const messages = defineMessages({ + cancel: { + defaultMessage: 'Cancel', + id: 'reply_indicator.cancel', + }, +}); + + +export default @injectIntl +class ReplyIndicator extends ImmutablePureComponent { + + static propTypes = { + status: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, + onCancel: PropTypes.func, + }; + + handleClick = () => { + const { onCancel } = this.props; + if (onCancel) { + onCancel(); + } + } + + // Rendering. + render () { + const { status, intl } = this.props; + + if (!status) { + return null; + } + + const account = status.get('account'); + const content = status.get('content'); + const attachments = status.get('media_attachments'); + + // The result. + return ( + <article className='composer--reply'> + <header> + <IconButton + className='cancel' + icon='times' + onClick={this.handleClick} + title={intl.formatMessage(messages.cancel)} + inverted + /> + {account && ( + <AccountContainer + id={account} + small + /> + )} + </header> + <div + className='content' + dangerouslySetInnerHTML={{ __html: content || '' }} + style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }} + /> + {attachments.size > 0 && ( + <AttachmentList + compact + media={attachments} + /> + )} + </article> + ); + } + +} 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..12d839637 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search.js @@ -0,0 +1,177 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import spring from 'react-motion/lib/spring'; +import { + injectIntl, + FormattedMessage, + defineMessages, +} from 'react-intl'; +import Overlay from 'react-overlays/lib/Overlay'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { focusRoot } from 'flavours/glitch/util/dom_helpers'; +import { searchEnabled } from 'flavours/glitch/util/initial_state'; +import Motion from 'flavours/glitch/util/optional_motion'; + +const messages = defineMessages({ + placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, +}); + +class SearchPopout extends React.PureComponent { + + static propTypes = { + style: PropTypes.object, + }; + + render () { + const { style } = this.props; + const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />; + return ( + <div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}> + <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> + + {extraInformation} + </div> + )} + </Motion> + </div> + ); + } + +} + +// The component. +export default @injectIntl +class Search extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; + + 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, + openInRoute: PropTypes.bool, + intl: PropTypes.object.isRequired, + singleColumn: PropTypes.bool, + }; + + state = { + expanded: false, + }; + + setRef = c => { + this.searchForm = c; + } + + handleChange = (e) => { + const { onChange } = this.props; + if (onChange) { + onChange(e.target.value); + } + } + + handleClear = (e) => { + const { + onClear, + submitted, + value, + } = this.props; + e.preventDefault(); // Prevents focus change ?? + if (onClear && (submitted || value && value.length)) { + onClear(); + } + } + + handleBlur = () => { + this.setState({ expanded: false }); + } + + handleFocus = () => { + this.setState({ expanded: true }); + this.props.onShow(); + + if (this.searchForm && !this.props.singleColumn) { + const { left, right } = this.searchForm.getBoundingClientRect(); + if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) { + this.searchForm.scrollIntoView(); + } + } + } + + handleKeyUp = (e) => { + const { onSubmit } = this.props; + switch (e.key) { + case 'Enter': + onSubmit(); + + if (this.props.openInRoute) { + this.context.router.history.push('/search'); + } + break; + case 'Escape': + focusRoot(); + } + } + + 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 + ref={this.setRef} + className='search__input' + type='text' + placeholder={intl.formatMessage(messages.placeholder)} + value={value || ''} + onChange={this.handleChange} + onKeyUp={this.handleKeyUp} + onFocus={this.handleFocus} + onBlur={this.handleBlur} + /> + </label> + + <div + aria-label={intl.formatMessage(messages.placeholder)} + className='search__icon' + onClick={this.handleClear} + role='button' + tabIndex='0' + > + <Icon id='search' className={hasValue ? '' : 'active'} /> + <Icon id='times-circle' className={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..fa3487328 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js @@ -0,0 +1,134 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import AccountContainer from 'flavours/glitch/containers/account_container'; +import StatusContainer from 'flavours/glitch/containers/status_container'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Hashtag from 'flavours/glitch/components/hashtag'; +import Icon from 'flavours/glitch/components/icon'; +import { searchEnabled } from 'flavours/glitch/util/initial_state'; +import LoadMore from 'flavours/glitch/components/load_more'; + +const messages = defineMessages({ + dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' }, +}); + +export default @injectIntl +class SearchResults extends ImmutablePureComponent { + + static propTypes = { + results: ImmutablePropTypes.map.isRequired, + suggestions: ImmutablePropTypes.list.isRequired, + fetchSuggestions: PropTypes.func.isRequired, + expandSearch: PropTypes.func.isRequired, + dismissSuggestion: PropTypes.func.isRequired, + searchTerm: PropTypes.string, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + if (this.props.searchTerm === '') { + this.props.fetchSuggestions(); + } + } + + handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); + + handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); + + handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); + + render () { + const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props; + + if (results.isEmpty() && !suggestions.isEmpty()) { + return ( + <div className='drawer--results'> + <div className='trends'> + <div className='trends__header'> + <Icon fixedWidth id='user-plus' /> + <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' /> + </div> + + {suggestions && suggestions.map(accountId => ( + <AccountContainer + key={accountId} + id={accountId} + actionIcon='times' + actionTitle={intl.formatMessage(messages.dismissSuggestion)} + onActionClick={dismissSuggestion} + /> + ))} + </div> + </div> + ); + } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) { + statuses = ( + <section> + <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5> + + <div className='search-results__info'> + <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' /> + </div> + </section> + ); + } + + let accounts, statuses, hashtags; + let count = 0; + + if (results.get('accounts') && results.get('accounts').size > 0) { + count += results.get('accounts').size; + accounts = ( + <section> + <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5> + + {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)} + + {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} + </section> + ); + } + + if (results.get('statuses') && results.get('statuses').size > 0) { + count += results.get('statuses').size; + statuses = ( + <section> + <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5> + + {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)} + + {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />} + </section> + ); + } + + if (results.get('hashtags') && results.get('hashtags').size > 0) { + count += results.get('hashtags').size; + hashtags = ( + <section> + <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> + + {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + + {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} + </section> + ); + } + + // The result. + return ( + <div className='drawer--results'> + <header className='search-results__header'> + <Icon id='search' fixedWidth /> + <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} /> + </header> + + {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..7f2005060 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const iconStyle = { + height: null, + lineHeight: '27px', + width: `${18 * 1.28571429}px`, +}; + +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} + style={iconStyle} + > + {label} + </button> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js new file mode 100644 index 000000000..b875fb15e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js @@ -0,0 +1,59 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Messages. +const messages = defineMessages({ + localOnly: { + defaultMessage: 'This post is local-only', + id: 'advanced_options.local-only.tooltip', + }, + threadedMode: { + defaultMessage: 'Threaded mode enabled', + id: 'advanced_options.threaded_mode.tooltip', + }, +}); + +// We use an array of tuples here instead of an object because it +// preserves order. +const iconMap = [ + ['do_not_federate', 'home', messages.localOnly], + ['threaded_mode', 'comments', messages.threadedMode], +]; + +export default @injectIntl +class TextareaIcons extends ImmutablePureComponent { + + static propTypes = { + advancedOptions: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, + }; + + render () { + const { advancedOptions, intl } = this.props; + return ( + <div className='composer--textarea--icons'> + {advancedOptions ? iconMap.map( + ([key, icon, message]) => advancedOptions.get(key) ? ( + <span + className='textarea_icon' + key={key} + title={intl.formatMessage(message)} + > + <Icon + fixedWidth + id={icon} + /> + </span> + ) : null + ) : null} + </div> + ); + } +} 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..425b0fe5e --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -0,0 +1,57 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import Icon from 'flavours/glitch/components/icon'; +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; + +export default class Upload extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + onUndo: PropTypes.func.isRequired, + onOpenFocalPoint: PropTypes.func.isRequired, + }; + + handleUndoClick = e => { + e.stopPropagation(); + this.props.onUndo(this.props.media.get('id')); + } + + handleFocalPointClick = e => { + e.stopPropagation(); + this.props.onOpenFocalPoint(this.props.media.get('id')); + } + + render () { + const { intl, media } = this.props; + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + return ( + <div className='composer--upload_form--item' tabIndex='0' role='button'> + <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}> + {({ scale }) => ( + <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> + <div className={classNames('composer--upload_form--actions', { active: true })}> + <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> + <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> + </div> + </div> + )} + </Motion> + </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..43039c674 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js @@ -0,0 +1,34 @@ +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'; +import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import { FormattedMessage } from 'react-intl'; + +export default class UploadForm extends ImmutablePureComponent { + static propTypes = { + mediaIds: ImmutablePropTypes.list.isRequired, + }; + + render () { + const { mediaIds } = this.props; + + return ( + <div className='composer--upload_form'> + <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} /> + + {mediaIds.size > 0 && ( + <div className='content'> + {mediaIds.map(id => ( + <UploadContainer id={id} key={id} /> + ))} + </div> + )} + + {!mediaIds.isEmpty() && <SensitiveButtonContainer />} + </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..493bb9ca5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js @@ -0,0 +1,43 @@ +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 Icon from 'flavours/glitch/components/icon'; + +export default class UploadProgress extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + progress: PropTypes.number, + icon: PropTypes.string.isRequired, + message: PropTypes.node.isRequired, + }; + + render () { + const { active, progress, icon, message } = this.props; + + if (!active) { + return null; + } + + return ( + <div className='composer--upload_form--progress'> + <Icon id={icon} /> + + <div className='message'> + {message} + + <div className='backdrop'> + <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> + {({ width }) => + (<div className='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..6ee3640bc --- /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='composer--warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}> + {message} + </div> + )} + </Motion> + ); + } + +} 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..fcd2caf1b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -0,0 +1,135 @@ +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import ComposeForm from '../components/compose_form'; +import { + changeCompose, + changeComposeSpoilerText, + changeComposeSpoilerness, + changeComposeVisibility, + clearComposeSuggestions, + fetchComposeSuggestions, + insertEmojiCompose, + selectComposeSuggestion, + submitCompose, + uploadCompose, +} from 'flavours/glitch/actions/compose'; +import { + openModal, +} from 'flavours/glitch/actions/modal'; +import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; + +import { privacyPreference } from 'flavours/glitch/util/privacy_preference'; + +const messages = defineMessages({ + missingDescriptionMessage: { id: 'confirmations.missing_media_description.message', + defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' }, + missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm', + defaultMessage: 'Send anyway' }, + missingDescriptionEdit: { id: 'confirmations.missing_media_description.edit', + defaultMessage: 'Edit media' }, +}); + +// State mapping. +function mapStateToProps (state) { + const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); + const inReplyTo = state.getIn(['compose', 'in_reply_to']); + const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null; + const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']); + const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null; + let sideArmPrivacy = null; + switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) { + case 'copy': + sideArmPrivacy = replyPrivacy; + break; + case 'restrict': + sideArmPrivacy = sideArmRestrictedPrivacy; + break; + } + sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy; + return { + advancedOptions: state.getIn(['compose', 'advanced_options']), + focusDate: state.getIn(['compose', 'focusDate']), + caretPosition: state.getIn(['compose', 'caretPosition']), + isSubmitting: state.getIn(['compose', 'is_submitting']), + isChangingUpload: state.getIn(['compose', 'is_changing_upload']), + isUploading: state.getIn(['compose', 'is_uploading']), + layout: state.getIn(['local_settings', 'layout']), + media: state.getIn(['compose', 'media_attachments']), + preselectDate: state.getIn(['compose', 'preselectDate']), + privacy: state.getIn(['compose', 'privacy']), + sideArm: sideArmPrivacy, + sensitive: state.getIn(['compose', 'sensitive']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']), + spoilerText: state.getIn(['compose', 'spoiler_text']), + suggestions: state.getIn(['compose', 'suggestions']), + text: state.getIn(['compose', 'text']), + anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, + spoilersAlwaysOn: spoilersAlwaysOn, + mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), + preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), + }; +}; + +// Dispatch mapping. +const mapDispatchToProps = (dispatch, { intl }) => ({ + + onChange(text) { + dispatch(changeCompose(text)); + }, + + onSubmit(routerHistory) { + dispatch(submitCompose(routerHistory)); + }, + + onClearSuggestions() { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions(token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected(position, token, suggestion, path) { + dispatch(selectComposeSuggestion(position, token, suggestion, path)); + }, + + onChangeSpoilerText(text) { + dispatch(changeComposeSpoilerText(text)); + }, + + onPaste(files) { + dispatch(uploadCompose(files)); + }, + + onPickEmoji(position, emoji) { + dispatch(insertEmojiCompose(position, emoji)); + }, + + onChangeSpoilerness() { + dispatch(changeComposeSpoilerness()); + }, + + onChangeVisibility(value) { + dispatch(changeComposeVisibility(value)); + }, + + onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.missingDescriptionMessage), + confirm: intl.formatMessage(messages.missingDescriptionConfirm), + onConfirm: () => { + if (overriddenVisibility) { + dispatch(changeComposeVisibility(overriddenVisibility)); + }; + dispatch(submitCompose(routerHistory)); + }, + secondary: intl.formatMessage(messages.missingDescriptionEdit), + onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })), + onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)), + })); + }, + +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ComposeForm)); diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js new file mode 100644 index 000000000..b4dcb4d56 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js @@ -0,0 +1,35 @@ +import { openModal } from 'flavours/glitch/actions/modal'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import Header from '../components/header'; +import { logOut } from 'flavours/glitch/util/log_out'; + +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + +const mapStateToProps = state => { + return { + columns: state.getIn(['settings', 'columns']), + unreadNotifications: state.getIn(['notifications', 'unread']), + showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']), + }; +}; + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onSettingsClick (e) { + e.preventDefault(); + e.stopPropagation(); + dispatch(openModal('SETTINGS', {})); + }, + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header)); 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/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js new file mode 100644 index 000000000..c792aa582 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js @@ -0,0 +1,61 @@ +import { connect } from 'react-redux'; +import Options from '../components/options'; +import { + changeComposeAdvancedOption, + changeComposeContentType, + addPoll, + removePoll, +} from 'flavours/glitch/actions/compose'; +import { closeModal, openModal } from 'flavours/glitch/actions/modal'; + +function mapStateToProps (state) { + const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); + const poll = state.getIn(['compose', 'poll']); + const media = state.getIn(['compose', 'media_attachments']); + const pending_media = state.getIn(['compose', 'pending_media_attachments']); + return { + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), + resetFileKey: state.getIn(['compose', 'resetFileKey']), + hasPoll: !!poll, + allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4), + hasMedia: media && !!media.size, + allowPoll: !(media && !!media.size), + showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']), + contentType: state.getIn(['compose', 'content_type']), + }; +}; + +const mapDispatchToProps = (dispatch) => ({ + + onChangeAdvancedOption(option, value) { + dispatch(changeComposeAdvancedOption(option, value)); + }, + + onChangeContentType(value) { + dispatch(changeComposeContentType(value)); + }, + + onTogglePoll() { + dispatch((_, getState) => { + if (getState().getIn(['compose', 'poll'])) { + dispatch(removePoll()); + } else { + dispatch(addPoll()); + } + }); + }, + + onDoodleOpen() { + dispatch(openModal('DOODLE', { noEsc: true })); + }, + + onModalClose() { + dispatch(closeModal()); + }, + + onModalOpen(props) { + dispatch(openModal('ACTIONS', props)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Options); diff --git a/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js new file mode 100644 index 000000000..e87e58771 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js @@ -0,0 +1,48 @@ +import { connect } from 'react-redux'; +import PollForm from '../components/poll_form'; +import { addPollOption, removePollOption, changePollOption, changePollSettings } from 'flavours/glitch/actions/compose'; +import { + clearComposeSuggestions, + fetchComposeSuggestions, + selectComposeSuggestion, +} from 'flavours/glitch/actions/compose'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['compose', 'suggestions']), + options: state.getIn(['compose', 'poll', 'options']), + expiresIn: state.getIn(['compose', 'poll', 'expires_in']), + isMultiple: state.getIn(['compose', 'poll', 'multiple']), +}); + +const mapDispatchToProps = dispatch => ({ + onAddOption(title) { + dispatch(addPollOption(title)); + }, + + onRemoveOption(index) { + dispatch(removePollOption(index)); + }, + + onChangeOption(index, title) { + dispatch(changePollOption(index, title)); + }, + + onChangeSettings(expiresIn, isMultiple) { + dispatch(changePollSettings(expiresIn, isMultiple)); + }, + + onClearSuggestions () { + dispatch(clearComposeSuggestions()); + }, + + onFetchSuggestions (token) { + dispatch(fetchComposeSuggestions(token)); + }, + + onSuggestionSelected (position, token, accountId, path) { + dispatch(selectComposeSuggestion(position, token, accountId, path)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(PollForm); 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..395a9aa5b --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { cancelReplyCompose } from 'flavours/glitch/actions/compose'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import ReplyIndicator from '../components/reply_indicator'; + +function makeMapStateToProps (state) { + const inReplyTo = state.getIn(['compose', 'in_reply_to']); + + return { + status: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null, + }; +}; + +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..5c2c1be23 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js @@ -0,0 +1,18 @@ +import { connect } from 'react-redux'; +import SearchResults from '../components/search_results'; +import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions'; +import { expandSearch } from 'flavours/glitch/actions/search'; + +const mapStateToProps = state => ({ + results: state.getIn(['search', 'results']), + suggestions: state.getIn(['suggestions', 'items']), + searchTerm: state.getIn(['search', 'searchTerm']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchSuggestions: () => dispatch(fetchSuggestions()), + expandSearch: type => dispatch(expandSearch(type)), + dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); 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..fa1ae8821 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js @@ -0,0 +1,63 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { changeComposeSensitivity } from 'flavours/glitch/actions/compose'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; + +const messages = defineMessages({ + marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, + unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' }, +}); + +const mapStateToProps = state => { + const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']); + const spoilerText = state.getIn(['compose', 'spoiler_text']); + return { + active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0), + disabled: state.getIn(['compose', 'spoiler']), + }; +}; + +const mapDispatchToProps = dispatch => ({ + + onClick () { + dispatch(changeComposeSensitivity()); + }, + +}); + +class SensitiveButton extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render () { + const { active, disabled, onClick, intl } = this.props; + + return ( + <div className='compose-form__sensitive-button'> + <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}> + <input + name='mark-sensitive' + type='checkbox' + checked={active} + onChange={onClick} + disabled={disabled} + /> + + <span className={classNames('checkbox', { active })} /> + + <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' /> + </label> + </div> + ); + } + +} + +export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton)); 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..f687fae99 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import Upload from '../components/upload'; +import { undoUploadCompose } from 'flavours/glitch/actions/compose'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { submitCompose } 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)); + }, + + onOpenFocalPoint: id => { + dispatch(openModal('FOCAL_POINT', { id })); + }, + + onSubmit (router) { + dispatch(submitCompose(router)); + }, + +}); + +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..b9b0a2644 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js @@ -0,0 +1,45 @@ +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'; +import { profileLink, termsLink } from 'flavours/glitch/util/backend_links'; + +const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i; + +const mapStateToProps = state => ({ + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), + hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])), + directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', +}); + +const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { + 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={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + } + + if (hashtagWarning) { + return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />; + } + + if (directMessageWarning) { + const message = ( + <span> + <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> {!!termsLink && <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>} + </span> + ); + + return <Warning message={message} />; + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLockWarning: PropTypes.bool, + hashtagWarning: PropTypes.bool, + directMessageWarning: 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..cb6c54a48 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/index.js @@ -0,0 +1,111 @@ +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 { injectIntl, defineMessages } from 'react-intl'; +import classNames from 'classnames'; +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 { me, mascot } from 'flavours/glitch/util/initial_state'; +import { cycleElefriendCompose } from 'flavours/glitch/actions/compose'; +import HeaderContainer from './containers/header_container'; + +const messages = defineMessages({ + compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' }, +}); + +const mapStateToProps = (state, ownProps) => ({ + elefriend: state.getIn(['compose', 'elefriend']), + showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onClickElefriend () { + dispatch(cycleElefriendCompose()); + }, + + onMount () { + dispatch(mountCompose()); + }, + + onUnmount () { + dispatch(unmountCompose()); + }, +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class Compose extends React.PureComponent { + static propTypes = { + multiColumn: PropTypes.bool, + showSearch: PropTypes.bool, + isSearchPage: PropTypes.bool, + elefriend: PropTypes.number, + onClickElefriend: PropTypes.func, + onMount: PropTypes.func, + onUnmount: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + componentDidMount () { + const { isSearchPage } = this.props; + + if (!isSearchPage) { + this.props.onMount(); + } + } + + componentWillUnmount () { + const { isSearchPage } = this.props; + + if (!isSearchPage) { + this.props.onUnmount(); + } + } + + render () { + const { + elefriend, + intl, + multiColumn, + onClickElefriend, + isSearchPage, + showSearch, + } = this.props; + const computedClass = classNames('drawer', `mbstobon-${elefriend}`); + + return ( + <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}> + {multiColumn && <HeaderContainer />} + + {(multiColumn || isSearchPage) && <SearchContainer />} + + <div className='drawer__pager'> + {!isSearchPage && <div className='drawer__inner'> + <NavigationContainer /> + + <ComposeFormContainer /> + + <div className='drawer__inner__mastodon'> + {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />} + </div> + </div>} + + <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => ( + <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + <SearchResultsContainer /> + </div> + )} + </Motion> + </div> + </div> + ); + } + +} |