diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features/composer')
18 files changed, 2674 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/composer/direct_warning/index.js b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js new file mode 100644 index 000000000..d1febdd1b --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js @@ -0,0 +1,53 @@ +import React from 'react'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { defineMessages, FormattedMessage } from 'react-intl'; + +// This is the spring used with our motion. +const motionSpring = spring(1, { damping: 35, stiffness: 400 }); + +// Messages. +const messages = defineMessages({ + disclaimer: { + defaultMessage: 'This toot will only be sent to all the mentioned users.', + id: 'compose_form.direct_message_warning', + }, + learn_more: { + defaultMessage: 'Learn more', + id: 'compose_form.direct_message_warning_learn_more' + } +}); + +// The component. +export default function ComposerDirectWarning () { + return ( + <Motion + defaultStyle={{ + opacity: 0, + scaleX: 0.85, + scaleY: 0.75, + }} + style={{ + opacity: motionSpring, + scaleX: motionSpring, + scaleY: motionSpring, + }} + > + {({ opacity, scaleX, scaleY }) => ( + <div + className='composer--warning' + style={{ + opacity: opacity, + transform: `scale(${scaleX}, ${scaleY})`, + }} + > + <span> + <FormattedMessage {...messages.disclaimer} /> <a href='/terms' target='_blank'><FormattedMessage {...messages.learn_more} /></a> + </span> + </div> + )} + </Motion> + ); +} + +ComposerDirectWarning.propTypes = {}; diff --git a/app/javascript/flavours/glitch/features/composer/hashtag_warning/index.js b/app/javascript/flavours/glitch/features/composer/hashtag_warning/index.js new file mode 100644 index 000000000..716028e4c --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/hashtag_warning/index.js @@ -0,0 +1,49 @@ +import React from 'react'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { defineMessages, FormattedMessage } from 'react-intl'; + +// This is the spring used with our motion. +const motionSpring = spring(1, { damping: 35, stiffness: 400 }); + +// Messages. +const messages = defineMessages({ + disclaimer: { + defaultMessage: 'This toot won\'t be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.', + id: 'compose_form.hashtag_warning', + }, +}); + +// The component. +export default function ComposerHashtagWarning () { + return ( + <Motion + defaultStyle={{ + opacity: 0, + scaleX: 0.85, + scaleY: 0.75, + }} + style={{ + opacity: motionSpring, + scaleX: motionSpring, + scaleY: motionSpring, + }} + > + {({ opacity, scaleX, scaleY }) => ( + <div + className='composer--warning' + style={{ + opacity: opacity, + transform: `scale(${scaleX}, ${scaleY})`, + }} + > + <FormattedMessage + {...messages.disclaimer} + /> + </div> + )} + </Motion> + ); +} + +ComposerHashtagWarning.propTypes = {}; diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js new file mode 100644 index 000000000..f312e9d59 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/index.js @@ -0,0 +1,522 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\S+)/i; + +// Actions. +import { + cancelReplyCompose, + changeCompose, + changeComposeAdvancedOption, + changeComposeSensitivity, + changeComposeSpoilerText, + changeComposeSpoilerness, + changeComposeVisibility, + changeUploadCompose, + clearComposeSuggestions, + fetchComposeSuggestions, + insertEmojiCompose, + mountCompose, + selectComposeSuggestion, + submitCompose, + undoUploadCompose, + unmountCompose, + uploadCompose, +} from 'flavours/glitch/actions/compose'; +import { + closeModal, + openModal, +} from 'flavours/glitch/actions/modal'; + +// Components. +import ComposerOptions from './options'; +import ComposerPublisher from './publisher'; +import ComposerReply from './reply'; +import ComposerSpoiler from './spoiler'; +import ComposerTextarea from './textarea'; +import ComposerUploadForm from './upload_form'; +import ComposerWarning from './warning'; +import ComposerHashtagWarning from './hashtag_warning'; +import ComposerDirectWarning from './direct_warning'; + +// Utils. +import { countableText } from 'flavours/glitch/util/counter'; +import { me } from 'flavours/glitch/util/initial_state'; +import { isMobile } from 'flavours/glitch/util/is_mobile'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; +import { wrap } from 'flavours/glitch/util/redux_helpers'; +import { privacyPreference } from 'flavours/glitch/util/privacy_preference'; + +// State mapping. +function mapStateToProps (state) { + 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 { + acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','), + advancedOptions: state.getIn(['compose', 'advanced_options']), + amUnlocked: !state.getIn(['accounts', me, 'locked']), + focusDate: state.getIn(['compose', 'focusDate']), + caretPosition: state.getIn(['compose', 'caretPosition']), + isSubmitting: state.getIn(['compose', 'is_submitting']), + 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']), + progress: state.getIn(['compose', 'progress']), + inReplyTo: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null, + replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null, + replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null, + resetFileKey: state.getIn(['compose', 'resetFileKey']), + sideArm: sideArmPrivacy, + sensitive: state.getIn(['compose', 'sensitive']), + showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + spoiler: state.getIn(['compose', 'spoiler']), + spoilerText: state.getIn(['compose', 'spoiler_text']), + suggestionToken: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + text: state.getIn(['compose', 'text']), + anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, + }; +}; + +// Dispatch mapping. +const mapDispatchToProps = (dispatch) => ({ + onCancelReply() { + dispatch(cancelReplyCompose()); + }, + onChangeAdvancedOption(option, value) { + dispatch(changeComposeAdvancedOption(option, value)); + }, + onChangeDescription(id, description) { + dispatch(changeUploadCompose(id, { description })); + }, + onChangeSensitivity() { + dispatch(changeComposeSensitivity()); + }, + onChangeSpoilerText(text) { + dispatch(changeComposeSpoilerText(text)); + }, + onChangeSpoilerness() { + dispatch(changeComposeSpoilerness()); + }, + onChangeText(text) { + dispatch(changeCompose(text)); + }, + onChangeVisibility(value) { + dispatch(changeComposeVisibility(value)); + }, + onClearSuggestions() { + dispatch(clearComposeSuggestions()); + }, + onCloseModal() { + dispatch(closeModal()); + }, + onFetchSuggestions(token) { + dispatch(fetchComposeSuggestions(token)); + }, + onInsertEmoji(position, emoji) { + dispatch(insertEmojiCompose(position, emoji)); + }, + onMount() { + dispatch(mountCompose()); + }, + onOpenActionModal(props) { + dispatch(openModal('ACTIONS', props)); + }, + onOpenDoodleModal() { + dispatch(openModal('DOODLE', { noEsc: true })); + }, + onOpenFocalPointModal(id) { + dispatch(openModal('FOCAL_POINT', { id })); + }, + onSelectSuggestion(position, token, suggestion) { + dispatch(selectComposeSuggestion(position, token, suggestion)); + }, + onSubmit() { + dispatch(submitCompose()); + }, + onUndoUpload(id) { + dispatch(undoUploadCompose(id)); + }, + onUnmount() { + dispatch(unmountCompose()); + }, + onUpload(files) { + dispatch(uploadCompose(files)); + }, +}); + +// Handlers. +const handlers = { + + // Changes the text value of the spoiler. + handleChangeSpoiler ({ target: { value } }) { + const { onChangeSpoilerText } = this.props; + if (onChangeSpoilerText) { + onChangeSpoilerText(value); + } + }, + + // Inserts an emoji at the caret. + handleEmoji (data) { + const { textarea: { selectionStart } } = this; + const { onInsertEmoji } = this.props; + if (onInsertEmoji) { + onInsertEmoji(selectionStart, data); + } + }, + + // Handles the secondary submit button. + handleSecondarySubmit () { + const { handleSubmit } = this.handlers; + const { + onChangeVisibility, + sideArm, + } = this.props; + if (sideArm !== 'none' && onChangeVisibility) { + onChangeVisibility(sideArm); + } + handleSubmit(); + }, + + // Selects a suggestion from the autofill. + handleSelect (tokenStart, token, value) { + const { onSelectSuggestion } = this.props; + if (onSelectSuggestion) { + onSelectSuggestion(tokenStart, token, value); + } + }, + + // Submits the status. + handleSubmit () { + const { textarea: { value } } = this; + const { + onChangeText, + onSubmit, + isSubmitting, + isUploading, + anyMedia, + text, + } = this.props; + + // If something changes inside the textarea, then we update the + // state before submitting. + if (onChangeText && text !== value) { + onChangeText(value); + } + + // Submit disabled: + if (isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia)) { + return; + } + + // Submits the status. + if (onSubmit) { + onSubmit(); + } + }, + + // Sets a reference to the textarea. + handleRefTextarea (textareaComponent) { + if (textareaComponent) { + this.textarea = textareaComponent.textarea; + } + }, + + // Sets a reference to the CW field. + handleRefSpoilerText (spoilerComponent) { + if (spoilerComponent) { + this.spoilerText = spoilerComponent.spoilerText; + } + } +}; + +// The component. +class Composer extends React.Component { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + + // Instance variables. + this.textarea = null; + this.spoilerText = null; + } + + // Tells our state the composer has been mounted. + componentDidMount () { + const { onMount } = this.props; + if (onMount) { + onMount(); + } + } + + // Tells our state the composer has been unmounted. + componentWillUnmount () { + const { onUnmount } = this.props; + if (onUnmount) { + onUnmount(); + } + } + + // 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, + } = this.props; + let selectionEnd, selectionStart; + + // Caret/selection handling. + if (focusDate !== prevProps.focusDate) { + switch (true) { + case preselectDate !== prevProps.preselectDate: + 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(); + } + + // 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 { + handleChangeSpoiler, + handleEmoji, + handleSecondarySubmit, + handleSelect, + handleSubmit, + handleRefTextarea, + handleRefSpoilerText, + } = this.handlers; + const { + acceptContentTypes, + advancedOptions, + amUnlocked, + anyMedia, + intl, + isSubmitting, + isUploading, + layout, + media, + onCancelReply, + onChangeAdvancedOption, + onChangeDescription, + onChangeSensitivity, + onChangeSpoilerness, + onChangeText, + onChangeVisibility, + onClearSuggestions, + onCloseModal, + onFetchSuggestions, + onOpenActionsModal, + onOpenDoodleModal, + onOpenFocalPointModal, + onUndoUpload, + onUpload, + privacy, + progress, + inReplyTo, + resetFileKey, + sensitive, + showSearch, + sideArm, + spoiler, + spoilerText, + suggestions, + text, + } = this.props; + + let disabledButton = isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia); + + return ( + <div className='composer'> + {privacy === 'direct' ? <ComposerDirectWarning /> : null} + {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null} + {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null} + {inReplyTo && ( + <ComposerReply + status={inReplyTo} + intl={intl} + onCancel={onCancelReply} + /> + )} + <ComposerSpoiler + hidden={!spoiler} + intl={intl} + onChange={handleChangeSpoiler} + onSubmit={handleSubmit} + text={spoilerText} + ref={handleRefSpoilerText} + /> + <ComposerTextarea + advancedOptions={advancedOptions} + autoFocus={!showSearch && !isMobile(window.innerWidth, layout)} + disabled={isSubmitting} + intl={intl} + onChange={onChangeText} + onPaste={onUpload} + onPickEmoji={handleEmoji} + onSubmit={handleSubmit} + onSecondarySubmit={handleSecondarySubmit} + onSuggestionsClearRequested={onClearSuggestions} + onSuggestionsFetchRequested={onFetchSuggestions} + onSuggestionSelected={handleSelect} + ref={handleRefTextarea} + suggestions={suggestions} + value={text} + /> + {isUploading || media && media.size ? ( + <ComposerUploadForm + intl={intl} + media={media} + onChangeDescription={onChangeDescription} + onOpenFocalPointModal={onOpenFocalPointModal} + onRemove={onUndoUpload} + progress={progress} + uploading={isUploading} + /> + ) : null} + <ComposerOptions + acceptContentTypes={acceptContentTypes} + advancedOptions={advancedOptions} + disabled={isSubmitting} + full={media ? media.size >= 4 || media.some( + item => item.get('type') === 'video' + ) : false} + hasMedia={media && !!media.size} + intl={intl} + onChangeAdvancedOption={onChangeAdvancedOption} + onChangeSensitivity={onChangeSensitivity} + onChangeVisibility={onChangeVisibility} + onDoodleOpen={onOpenDoodleModal} + onModalClose={onCloseModal} + onModalOpen={onOpenActionsModal} + onToggleSpoiler={onChangeSpoilerness} + onUpload={onUpload} + privacy={privacy} + resetFileKey={resetFileKey} + sensitive={sensitive} + spoiler={spoiler} + /> + <ComposerPublisher + countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`} + disabled={disabledButton} + intl={intl} + onSecondarySubmit={handleSecondarySubmit} + onSubmit={handleSubmit} + privacy={privacy} + sideArm={sideArm} + /> + </div> + ); + } + +} + +// Props. +Composer.propTypes = { + intl: PropTypes.object.isRequired, + + // State props. + acceptContentTypes: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, + amUnlocked: PropTypes.bool, + focusDate: PropTypes.instanceOf(Date), + caretPosition: PropTypes.number, + isSubmitting: PropTypes.bool, + isUploading: PropTypes.bool, + layout: PropTypes.string, + media: ImmutablePropTypes.list, + preselectDate: PropTypes.instanceOf(Date), + privacy: PropTypes.string, + progress: PropTypes.number, + inReplyTo: ImmutablePropTypes.map, + resetFileKey: PropTypes.number, + sideArm: PropTypes.string, + sensitive: PropTypes.bool, + showSearch: PropTypes.bool, + spoiler: PropTypes.bool, + spoilerText: PropTypes.string, + suggestionToken: PropTypes.string, + suggestions: ImmutablePropTypes.list, + text: PropTypes.string, + + // Dispatch props. + onCancelReply: PropTypes.func, + onChangeAdvancedOption: PropTypes.func, + onChangeDescription: PropTypes.func, + onChangeSensitivity: PropTypes.func, + onChangeSpoilerText: PropTypes.func, + onChangeSpoilerness: PropTypes.func, + onChangeText: PropTypes.func, + onChangeVisibility: PropTypes.func, + onClearSuggestions: PropTypes.func, + onCloseModal: PropTypes.func, + onFetchSuggestions: PropTypes.func, + onInsertEmoji: PropTypes.func, + onMount: PropTypes.func, + onOpenActionsModal: PropTypes.func, + onOpenDoodleModal: PropTypes.func, + onSelectSuggestion: PropTypes.func, + onSubmit: PropTypes.func, + onUndoUpload: PropTypes.func, + onUnmount: PropTypes.func, + onUpload: PropTypes.func, + anyMedia: PropTypes.bool, +}; + +// Connecting and export. +export { Composer as WrappedComponent }; +export default wrap(Composer, mapStateToProps, mapDispatchToProps, true); diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js new file mode 100644 index 000000000..b76410561 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js @@ -0,0 +1,146 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import spring from 'react-motion/lib/spring'; + +// Components. +import ComposerOptionsDropdownContentItem from './item'; + +// 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'; + +// Handlers. +const handlers = { + // When the document is clicked elsewhere, we close the dropdown. + handleDocumentClick ({ target }) { + const { node } = this; + const { onClose } = this.props; + if (onClose && node && !node.contains(target)) { + onClose(); + } + }, + + // Stores our node in `this.node`. + handleRef (node) { + this.node = node; + }, +}; + +// The spring to use with our motion. +const springMotion = spring(1, { + damping: 35, + stiffness: 400, +}); + +// The component. +export default class ComposerOptionsDropdownContent extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + + // Instance variables. + this.node = null; + + this.state = { + mounted: false, + }; + } + + // On mounting, we add our listeners. + componentDidMount () { + const { handleDocumentClick } = this.handlers; + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('touchend', handleDocumentClick, withPassive); + this.setState({ mounted: true }); + } + + // On unmounting, we remove our listeners. + componentWillUnmount () { + const { handleDocumentClick } = this.handlers; + document.removeEventListener('click', handleDocumentClick, false); + document.removeEventListener('touchend', handleDocumentClick, withPassive); + } + + // Rendering. + render () { + const { mounted } = this.state; + const { handleRef } = this.handlers; + const { + items, + onChange, + onClose, + style, + value, + } = 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={handleRef} + style={{ + ...style, + opacity: opacity, + transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, + }} + > + {items ? items.map( + ({ + name, + ...rest + }) => ( + <ComposerOptionsDropdownContentItem + active={name === value} + key={name} + name={name} + onChange={onChange} + onClose={onClose} + options={rest} + /> + ) + ) : null} + </div> + )} + </Motion> + ); + } + +} + +// Props. +ComposerOptionsDropdownContent.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, + onClose: PropTypes.func, + style: PropTypes.object, + value: PropTypes.string, +}; + +// Default props. +ComposerOptionsDropdownContent.defaultProps = { style: {} }; diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js new file mode 100644 index 000000000..68a52083f --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js @@ -0,0 +1,129 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Toggle from 'react-toggle'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Handlers. +const handlers = { + + // This function activates the dropdown item. + handleActivate (e) { + const { + name, + onChange, + onClose, + options: { on }, + } = this.props; + + // If the escape key was pressed, we close the dropdown. + if (e.key === 'Escape' && onClose) { + onClose(); + + // Otherwise, we both close the dropdown and change the value. + } else if (onChange && (!e.key || e.key === 'Enter')) { + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined') && onClose) { + onClose(); + } + onChange(name); + } + }, +}; + +// The component. +export default class ComposerOptionsDropdownContentItem extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { handleActivate } = this.handlers; + const { + active, + options: { + icon, + meta, + on, + text, + }, + } = this.props; + 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, + }); + + // The result. + return ( + <div + className={computedClass} + onClick={handleActivate} + onKeyDown={handleActivate} + role='button' + tabIndex='0' + > + {function () { + + // We render a `<Toggle>` if we were provided an `on` + // property, and otherwise show an `<Icon>` if available. + switch (true) { + case on !== null && typeof on !== 'undefined': + return ( + <Toggle + checked={on} + onChange={handleActivate} + /> + ); + case !!icon: + return ( + <Icon + className='icon' + fullwidth + icon={icon} + /> + ); + default: + return null; + } + }()} + {meta ? ( + <div className='content'> + <strong>{text}</strong> + {meta} + </div> + ) : + <div className='content'> + <strong>{text}</strong> + </div>} + </div> + ); + } + +}; + +// Props. +ComposerOptionsDropdownContentItem.propTypes = { + active: PropTypes.bool, + name: PropTypes.string, + onChange: PropTypes.func, + onClose: PropTypes.func, + options: PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + on: PropTypes.bool, + text: PropTypes.node, + }), +}; diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js new file mode 100644 index 000000000..8cfbac1bb --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js @@ -0,0 +1,229 @@ +// 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 ComposerOptionsDropdownContent from './content'; + +// Utils. +import { isUserTouching } from 'flavours/glitch/util/is_mobile'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Handlers. +const handlers = { + + // Closes the dropdown. + handleClose () { + this.setState({ open: false }); + }, + + // The enter key toggles the dropdown's open state, and the escape + // key closes it. + handleKeyDown ({ key }) { + const { + handleClose, + handleToggle, + } = this.handlers; + switch (key) { + case 'Enter': + handleToggle(key); + break; + case 'Escape': + handleClose(); + break; + } + }, + + // 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 }); + }, + }) + ), + }; + }, + + // Toggles opening and closing the dropdown. + handleToggle ({ target }) { + const { handleMakeModal } = this.handlers; + const { onModalOpen } = this.props; + const { open } = this.state; + + // If this is a touch device, we open a modal instead of the + // dropdown. + if (isUserTouching()) { + + // This gets the modal to open. + const modal = handleMakeModal(); + + // If we can, we then open the modal. + if (modal && onModalOpen) { + onModalOpen(modal); + return; + } + } + + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + // Otherwise, we just set our state to open. + this.setState({ open: !open }); + }, + + // If our modal is open and our props update, we need to also update + // the modal. + handleUpdate () { + const { handleMakeModal } = this.handlers; + const { onModalOpen } = this.props; + const { needsModalUpdate } = this.state; + + // Gets our modal object. + const modal = handleMakeModal(); + + // Reopens the modal with the new object. + if (needsModalUpdate && modal && onModalOpen) { + onModalOpen(modal); + } + }, +}; + +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + this.state = { + needsModalUpdate: false, + open: false, + placement: null, + }; + } + + // Updates our modal as necessary. + componentDidUpdate (prevProps) { + const { handleUpdate } = this.handlers; + const { items } = this.props; + const { needsModalUpdate } = this.state; + if (needsModalUpdate && items.find( + (item, i) => item.on !== prevProps.items[i].on + )) { + handleUpdate(); + this.setState({ needsModalUpdate: false }); + } + } + + // Rendering. + render () { + const { + handleClose, + handleKeyDown, + handleToggle, + } = this.handlers; + 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={handleKeyDown} + > + <IconButton + active={open || active} + className='value' + disabled={disabled} + icon={icon} + onClick={handleToggle} + size={18} + style={{ + height: null, + lineHeight: '27px', + }} + title={title} + /> + <Overlay + containerPadding={20} + placement={placement} + show={open} + target={this} + > + <ComposerOptionsDropdownContent + items={items} + onChange={onChange} + onClose={handleClose} + value={value} + /> + </Overlay> + </div> + ); + } + +} + +// Props. +ComposerOptionsDropdown.propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + icon: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })).isRequired, + onChange: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js new file mode 100644 index 000000000..c129622bc --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/options/index.js @@ -0,0 +1,344 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { + FormattedMessage, + defineMessages, +} from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; +import TextIconButton from 'flavours/glitch/components/text_icon_button'; +import Dropdown from './dropdown'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; +import { + assignHandlers, + hiddenComponent, +} from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + advanced_options_icon_title: { + defaultMessage: 'Advanced options', + id: 'advanced_options.icon_title', + }, + attach: { + defaultMessage: 'Attach...', + id: 'compose.attach', + }, + change_privacy: { + defaultMessage: 'Adjust status privacy', + id: 'privacy.change', + }, + direct_long: { + defaultMessage: 'Post to mentioned users only', + id: 'privacy.direct.long', + }, + direct_short: { + defaultMessage: 'Direct', + id: 'privacy.direct.short', + }, + doodle: { + defaultMessage: 'Draw something', + id: 'compose.attach.doodle', + }, + local_only_long: { + defaultMessage: 'Do not post to other instances', + id: 'advanced_options.local-only.long', + }, + local_only_short: { + defaultMessage: 'Local-only', + id: 'advanced_options.local-only.short', + }, + private_long: { + defaultMessage: 'Post to followers only', + id: 'privacy.private.long', + }, + private_short: { + defaultMessage: 'Followers-only', + id: 'privacy.private.short', + }, + public_long: { + defaultMessage: 'Post to public timelines', + id: 'privacy.public.long', + }, + public_short: { + defaultMessage: 'Public', + id: 'privacy.public.short', + }, + sensitive: { + defaultMessage: 'Mark media as sensitive', + id: 'compose_form.sensitive', + }, + spoiler: { + defaultMessage: 'Hide text behind warning', + id: 'compose_form.spoiler', + }, + 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: 'Do not show in public timelines', + id: 'privacy.unlisted.long', + }, + unlisted_short: { + defaultMessage: 'Unlisted', + id: 'privacy.unlisted.short', + }, + upload: { + defaultMessage: 'Upload a file', + id: 'compose.attach.upload', + }, +}); + +// Handlers. +const handlers = { + + // Handles file selection. + 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; + }, +}; + +// The component. +export default class ComposerOptions extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + + // Instance variables. + this.fileElement = null; + } + + // Rendering. + render () { + const { + handleChangeFiles, + handleClickAttach, + handleRefFileElement, + } = this.handlers; + const { + acceptContentTypes, + advancedOptions, + disabled, + full, + hasMedia, + intl, + onChangeAdvancedOption, + onChangeSensitivity, + onChangeVisibility, + onModalClose, + onModalOpen, + onToggleSpoiler, + privacy, + resetFileKey, + sensitive, + spoiler, + } = this.props; + + // We predefine our privacy items so that we can easily pick the + // dropdown icon later. + const privacyItems = { + direct: { + icon: 'envelope', + meta: <FormattedMessage {...messages.direct_long} />, + name: 'direct', + text: <FormattedMessage {...messages.direct_short} />, + }, + private: { + icon: 'lock', + meta: <FormattedMessage {...messages.private_long} />, + name: 'private', + text: <FormattedMessage {...messages.private_short} />, + }, + public: { + icon: 'globe', + meta: <FormattedMessage {...messages.public_long} />, + name: 'public', + text: <FormattedMessage {...messages.public_short} />, + }, + unlisted: { + icon: 'unlock-alt', + meta: <FormattedMessage {...messages.unlisted_long} />, + name: 'unlisted', + text: <FormattedMessage {...messages.unlisted_short} />, + }, + }; + + // The result. + return ( + <div className='composer--options'> + <input + accept={acceptContentTypes} + disabled={disabled || full} + key={resetFileKey} + onChange={handleChangeFiles} + ref={handleRefFileElement} + type='file' + {...hiddenComponent} + /> + <Dropdown + disabled={disabled || full} + icon='paperclip' + items={[ + { + icon: 'cloud-upload', + name: 'upload', + text: <FormattedMessage {...messages.upload} />, + }, + { + icon: 'paint-brush', + name: 'doodle', + text: <FormattedMessage {...messages.doodle} />, + }, + ]} + onChange={handleClickAttach} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.attach)} + /> + <Motion + defaultStyle={{ scale: 0.87 }} + style={{ + scale: spring(hasMedia ? 1 : 0.87, { + stiffness: 200, + damping: 3, + }), + }} + > + {({ scale }) => ( + <div + style={{ + display: hasMedia ? null : 'none', + transform: `scale(${scale})`, + }} + > + <IconButton + active={sensitive} + className='sensitive' + disabled={spoiler} + icon={sensitive ? 'eye-slash' : 'eye'} + inverted + onClick={onChangeSensitivity} + size={18} + style={{ + height: null, + lineHeight: null, + }} + title={intl.formatMessage(messages.sensitive)} + /> + </div> + )} + </Motion> + <hr /> + <Dropdown + disabled={disabled} + icon={(privacyItems[privacy] || {}).icon} + items={[ + privacyItems.public, + privacyItems.unlisted, + privacyItems.private, + privacyItems.direct, + ]} + onChange={onChangeVisibility} + onModalClose={onModalClose} + onModalOpen={onModalOpen} + title={intl.formatMessage(messages.change_privacy)} + value={privacy} + /> + <TextIconButton + active={spoiler} + ariaControls='glitch.composer.spoiler.input' + label='CW' + 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> + ); + } + +} + +// Props. +ComposerOptions.propTypes = { + acceptContentTypes: PropTypes.string, + advancedOptions: ImmutablePropTypes.map, + disabled: PropTypes.bool, + full: PropTypes.bool, + hasMedia: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChangeAdvancedOption: PropTypes.func, + onChangeSensitivity: PropTypes.func, + onChangeVisibility: PropTypes.func, + onDoodleOpen: PropTypes.func, + onModalClose: PropTypes.func, + onModalOpen: PropTypes.func, + onToggleSpoiler: PropTypes.func, + onUpload: PropTypes.func, + privacy: PropTypes.string, + resetFileKey: PropTypes.number, + sensitive: PropTypes.bool, + spoiler: PropTypes.bool, +}; diff --git a/app/javascript/flavours/glitch/features/composer/publisher/index.js b/app/javascript/flavours/glitch/features/composer/publisher/index.js new file mode 100644 index 000000000..5ded26f80 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/publisher/index.js @@ -0,0 +1,122 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { + defineMessages, + FormattedMessage, +} from 'react-intl'; +import { length } from 'stringz'; + +// Components. +import Button from 'flavours/glitch/components/button'; +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import { maxChars } from 'flavours/glitch/util/initial_state'; + +// Messages. +const messages = defineMessages({ + publish: { + defaultMessage: 'Toot', + id: 'compose_form.publish', + }, + publishLoud: { + defaultMessage: '{publish}!', + id: 'compose_form.publish_loud', + }, +}); + +// The component. +export default function ComposerPublisher ({ + countText, + disabled, + intl, + onSecondarySubmit, + onSubmit, + privacy, + sideArm, +}) { + const diff = maxChars - length(countText || ''); + const computedClass = classNames('composer--publisher', { + disabled: disabled || diff < 0, + over: diff < 0, + }); + + // The result. + return ( + <div className={computedClass}> + <span className='count'>{diff}</span> + {sideArm && sideArm !== 'none' ? ( + <Button + className='side_arm' + disabled={disabled || diff < 0} + onClick={onSecondarySubmit} + style={{ padding: null }} + text={ + <span> + <Icon + icon={{ + public: 'globe', + unlisted: 'unlock-alt', + private: 'lock', + direct: 'envelope', + }[sideArm]} + /> + </span> + } + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`} + /> + ) : null} + <Button + className='primary' + text={function () { + switch (true) { + case !!sideArm && sideArm !== 'none': + case privacy === 'direct': + case privacy === 'private': + return ( + <span> + <Icon + icon={{ + direct: 'envelope', + private: 'lock', + public: 'globe', + unlisted: 'unlock-alt', + }[privacy]} + /> + {' '} + <FormattedMessage {...messages.publish} /> + </span> + ); + case privacy === 'public': + return ( + <span> + <FormattedMessage + {...messages.publishLoud} + values={{ publish: <FormattedMessage {...messages.publish} /> }} + /> + </span> + ); + default: + return <span><FormattedMessage {...messages.publish} /></span>; + } + }()} + title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`} + onClick={onSubmit} + disabled={disabled || diff < 0} + /> + </div> + ); +} + +// Props. +ComposerPublisher.propTypes = { + countText: PropTypes.string, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onSecondarySubmit: PropTypes.func, + onSubmit: PropTypes.func, + privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']), + sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']), +}; diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js new file mode 100644 index 000000000..56e9e96a5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/reply/index.js @@ -0,0 +1,96 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages } from 'react-intl'; + +// 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 { assignHandlers } from 'flavours/glitch/util/react_helpers'; +import { isRtl } from 'flavours/glitch/util/rtl'; + +// Messages. +const messages = defineMessages({ + cancel: { + defaultMessage: 'Cancel', + id: 'reply_indicator.cancel', + }, +}); + +// Handlers. +const handlers = { + + // Handles a click on the "close" button. + handleClick () { + const { onCancel } = this.props; + if (onCancel) { + onCancel(); + } + }, +}; + +// The component. +export default class ComposerReply extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { handleClick } = this.handlers; + const { + status, + intl, + } = this.props; + + 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={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> + ); + } + +} + +ComposerReply.propTypes = { + status: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onCancel: PropTypes.func, +}; diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js new file mode 100644 index 000000000..a7fecbcf5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/spoiler/index.js @@ -0,0 +1,91 @@ +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, FormattedMessage } from 'react-intl'; + +// Utils. +import { + assignHandlers, + hiddenComponent, +} from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + placeholder: { + defaultMessage: 'Write your warning here', + id: 'compose_form.spoiler_placeholder', + }, +}); + +// Handlers. +const handlers = { + + // Handles a keypress. + handleKeyDown ({ + ctrlKey, + keyCode, + metaKey, + }) { + const { onSubmit } = this.props; + + // We submit the status on control/meta + enter. + if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) { + onSubmit(); + } + }, + + handleRefSpoilerText (spoilerText) { + this.spoilerText = spoilerText; + }, +}; + +// The component. +export default class ComposerSpoiler extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { handleKeyDown, handleRefSpoilerText } = this.handlers; + const { + hidden, + intl, + onChange, + text, + } = this.props; + + // The result. + return ( + <div className={`composer--spoiler ${hidden ? '' : 'composer--spoiler--visible'}`}> + <label> + <span {...hiddenComponent}> + <FormattedMessage {...messages.placeholder} /> + </span> + <input + id='glitch.composer.spoiler.input' + onChange={onChange} + onKeyDown={handleKeyDown} + placeholder={intl.formatMessage(messages.placeholder)} + type='text' + value={text} + ref={handleRefSpoilerText} + /> + </label> + </div> + ); + } + +} + +// Props. +ComposerSpoiler.propTypes = { + hidden: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChange: PropTypes.func, + onSubmit: PropTypes.func, + text: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js new file mode 100644 index 000000000..049cdd5cd --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js @@ -0,0 +1,60 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages } from 'react-intl'; + +// 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], +]; + +// The component. +export default function ComposerTextareaIcons ({ + advancedOptions, + intl, +}) { + + // The result. We just map every active option to its icon. + 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 + fullwidth + icon={icon} + /> + </span> + ) : null + ) : null} + </div> + ); +} + +// Props. +ComposerTextareaIcons.propTypes = { + advancedOptions: ImmutablePropTypes.map, + intl: PropTypes.object.isRequired, +}; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js new file mode 100644 index 000000000..51d44a83b --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js @@ -0,0 +1,312 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { + defineMessages, + FormattedMessage, +} from 'react-intl'; +import Textarea from 'react-textarea-autosize'; + +// Components. +import EmojiPicker from 'flavours/glitch/features/emoji_picker'; +import ComposerTextareaIcons from './icons'; +import ComposerTextareaSuggestions from './suggestions'; + +// Utils. +import { isRtl } from 'flavours/glitch/util/rtl'; +import { + assignHandlers, + hiddenComponent, +} from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + placeholder: { + defaultMessage: 'What is on your mind?', + id: 'compose_form.placeholder', + }, +}); + +// Handlers. +const handlers = { + + // When blurring the textarea, suggestions are hidden. + handleBlur () { + this.setState({ suggestionsHidden: true }); + }, + + // When the contents of the textarea change, we have to pull up new + // autosuggest suggestions if applicable, and also change the value + // of the textarea in our store. + handleChange ({ + target: { + selectionStart, + value, + }, + }) { + const { + onChange, + onSuggestionsFetchRequested, + onSuggestionsClearRequested, + } = this.props; + const { lastToken } = this.state; + + // This gets the token at the caret location, if it begins with an + // `@` (mentions) or `:` (shortcodes). + const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/); + const right = value.slice(selectionStart).search(/[\s\u200B]/); + const token = function () { + switch (true) { + case left < 0 || !/[@:]/.test(value[left]): + return null; + case right < 0: + return value.slice(left); + default: + return value.slice(left, right + selectionStart).trim().toLowerCase(); + } + }(); + + // We only request suggestions for tokens which are at least 3 + // characters long. + if (onSuggestionsFetchRequested && token && token.length >= 3) { + if (lastToken !== token) { + this.setState({ + lastToken: token, + selectedSuggestion: 0, + tokenStart: left, + }); + onSuggestionsFetchRequested(token); + } + } else { + this.setState({ lastToken: null }); + if (onSuggestionsClearRequested) { + onSuggestionsClearRequested(); + } + } + + // Updates the value of the textarea. + if (onChange) { + onChange(value); + } + }, + + // Handles a click on an autosuggestion. + handleClickSuggestion (index) { + const { textarea } = this; + const { + onSuggestionSelected, + suggestions, + } = this.props; + const { + lastToken, + tokenStart, + } = this.state; + onSuggestionSelected(tokenStart, lastToken, suggestions.get(index)); + textarea.focus(); + }, + + // Handles a keypress. If the autosuggestions are visible, we need + // to allow keypresses to navigate and sleect them. + handleKeyDown (e) { + const { + disabled, + onSubmit, + onSecondarySubmit, + onSuggestionSelected, + suggestions, + } = this.props; + const { + lastToken, + suggestionsHidden, + selectedSuggestion, + tokenStart, + } = this.state; + + // Keypresses do nothing if the composer is disabled. + if (disabled) { + e.preventDefault(); + return; + } + + // We submit the status on control/meta + enter. + if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { + onSubmit(); + } + + // Submit the status with secondary visibility on alt + enter. + if (onSecondarySubmit && e.keyCode === 13 && e.altKey) { + onSecondarySubmit(); + } + + // Switches over the pressed key. + switch(e.key) { + + // On arrow down, we pick the next suggestion. + case 'ArrowDown': + if (suggestions && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + } + return; + + // On arrow up, we pick the previous suggestion. + case 'ArrowUp': + if (suggestions && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + } + return; + + // On enter or tab, we select the suggestion. + case 'Enter': + case 'Tab': + if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) { + e.preventDefault(); + e.stopPropagation(); + onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion)); + } + return; + } + }, + + // When the escape key is released, we either close the suggestions + // window or focus the UI. + handleKeyUp ({ key }) { + const { suggestionsHidden } = this.state; + if (key === 'Escape') { + if (!suggestionsHidden) { + this.setState({ suggestionsHidden: true }); + } else { + document.querySelector('.ui').parentElement.focus(); + } + } + }, + + // Handles the pasting of images into the composer. + handlePaste (e) { + const { onPaste } = this.props; + let d; + if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) { + onPaste(d); + e.preventDefault(); + } + }, + + // Saves a reference to the textarea. + handleRefTextarea (textarea) { + this.textarea = textarea; + }, +}; + +// The component. +export default class ComposerTextarea extends React.Component { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + this.state = { + suggestionsHidden: false, + selectedSuggestion: 0, + lastToken: null, + tokenStart: 0, + }; + + // Instance variables. + this.textarea = null; + } + + // When we receive new suggestions, we unhide the suggestions window + // if we didn't have any suggestions before. + componentWillReceiveProps (nextProps) { + const { suggestions } = this.props; + const { suggestionsHidden } = this.state; + if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) { + this.setState({ suggestionsHidden: false }); + } + } + + // Rendering. + render () { + const { + handleBlur, + handleChange, + handleClickSuggestion, + handleKeyDown, + handleKeyUp, + handlePaste, + handleRefTextarea, + } = this.handlers; + const { + advancedOptions, + autoFocus, + disabled, + intl, + onPickEmoji, + suggestions, + value, + } = this.props; + const { + selectedSuggestion, + suggestionsHidden, + } = this.state; + + // The result. + return ( + <div className='composer--textarea'> + <label> + <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span> + <ComposerTextareaIcons + advancedOptions={advancedOptions} + intl={intl} + /> + <Textarea + aria-autocomplete='list' + autoFocus={autoFocus} + className='textarea' + disabled={disabled} + inputRef={handleRefTextarea} + onBlur={handleBlur} + onChange={handleChange} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onPaste={handlePaste} + placeholder={intl.formatMessage(messages.placeholder)} + value={value} + style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }} + /> + </label> + <EmojiPicker onPickEmoji={onPickEmoji} /> + <ComposerTextareaSuggestions + hidden={suggestionsHidden} + onSuggestionClick={handleClickSuggestion} + suggestions={suggestions} + value={selectedSuggestion} + /> + </div> + ); + } + +} + +// Props. +ComposerTextarea.propTypes = { + advancedOptions: ImmutablePropTypes.map, + autoFocus: PropTypes.bool, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChange: PropTypes.func, + onPaste: PropTypes.func, + onPickEmoji: PropTypes.func, + onSubmit: PropTypes.func, + onSecondarySubmit: PropTypes.func, + onSuggestionsClearRequested: PropTypes.func, + onSuggestionsFetchRequested: PropTypes.func, + onSuggestionSelected: PropTypes.func, + suggestions: ImmutablePropTypes.list, + value: PropTypes.string, +}; + +// Default props. +ComposerTextarea.defaultProps = { autoFocus: true }; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js new file mode 100644 index 000000000..dc72585f2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js @@ -0,0 +1,43 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Components. +import ComposerTextareaSuggestionsItem from './item'; + +// The component. +export default function ComposerTextareaSuggestions ({ + hidden, + onSuggestionClick, + suggestions, + value, +}) { + + // The result. + return ( + <div + className='composer--textarea--suggestions' + hidden={hidden || !suggestions || suggestions.isEmpty()} + > + {!hidden && suggestions ? suggestions.map( + (suggestion, index) => ( + <ComposerTextareaSuggestionsItem + index={index} + key={typeof suggestion === 'object' ? suggestion.id : suggestion} + onClick={onSuggestionClick} + selected={index === value} + suggestion={suggestion} + /> + ) + ) : null} + </div> + ); +} + +ComposerTextareaSuggestions.propTypes = { + hidden: PropTypes.bool, + onSuggestionClick: PropTypes.func, + suggestions: ImmutablePropTypes.list, + value: PropTypes.number, +}; diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js new file mode 100644 index 000000000..f55640bcf --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js @@ -0,0 +1,112 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// Components. +import AccountContainer from 'flavours/glitch/containers/account_container'; + +// Utils. +import { unicodeMapping } from 'flavours/glitch/util/emoji'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Gets our asset host from the environment, if available. +const assetHost = ((process || {}).env || {}).CDN_HOST || ''; + +// Handlers. +const handlers = { + + // Handles a click on a suggestion. + handleClick (e) { + const { + index, + onClick, + } = this.props; + if (onClick) { + e.preventDefault(); + e.stopPropagation(); // Prevents following account links + onClick(index); + } + }, + + // This prevents the focus from changing, which would mess with + // our suggestion code. + handleMouseDown (e) { + e.preventDefault(); + }, +}; + +// The component. +export default class ComposerTextareaSuggestionsItem extends React.Component { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + } + + // Rendering. + render () { + const { + handleMouseDown, + handleClick, + } = this.handlers; + const { + selected, + suggestion, + } = this.props; + const computedClass = classNames('composer--textarea--suggestions--item', { selected }); + + // The result. + return ( + <div + className={computedClass} + onMouseDown={handleMouseDown} + onClickCapture={handleClick} // Jumps in front of contents + role='button' + tabIndex='0' + > + { // If the suggestion is an object, then we render an emoji. + // Otherwise, we render an account. + typeof suggestion === 'object' ? function () { + const url = function () { + if (suggestion.custom) { + return suggestion.imageUrl; + } else { + const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')]; + if (!mapping) { + return null; + } + return `${assetHost}/emoji/${mapping.filename}.svg`; + } + }(); + return url ? ( + <div className='emoji'> + <img + alt={suggestion.native || suggestion.colons} + className='emojione' + src={url} + /> + {suggestion.colons} + </div> + ) : null; + }() : ( + <AccountContainer + id={suggestion} + small + /> + ) + } + </div> + ); + } + +} + +// Props. +ComposerTextareaSuggestionsItem.propTypes = { + index: PropTypes.number, + onClick: PropTypes.func, + selected: PropTypes.bool, + suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), +}; diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/index.js new file mode 100644 index 000000000..f3cadc2f5 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/upload_form/index.js @@ -0,0 +1,58 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Components. +import ComposerUploadFormItem from './item'; +import ComposerUploadFormProgress from './progress'; + +// The component. +export default function ComposerUploadForm ({ + intl, + media, + onChangeDescription, + onOpenFocalPointModal, + onRemove, + progress, + uploading, +}) { + const computedClass = classNames('composer--upload_form', { uploading }); + + // The result. + return ( + <div className={computedClass}> + {uploading ? <ComposerUploadFormProgress progress={progress} /> : null} + {media ? ( + <div className='content'> + {media.map(item => ( + <ComposerUploadFormItem + description={item.get('description')} + key={item.get('id')} + id={item.get('id')} + intl={intl} + focusX={item.getIn(['meta', 'focus', 'x'])} + focusY={item.getIn(['meta', 'focus', 'y'])} + mediaType={item.get('type')} + preview={item.get('preview_url')} + onChangeDescription={onChangeDescription} + onOpenFocalPointModal={onOpenFocalPointModal} + onRemove={onRemove} + /> + ))} + </div> + ) : null} + </div> + ); +} + +// Props. +ComposerUploadForm.propTypes = { + intl: PropTypes.object.isRequired, + media: ImmutablePropTypes.list, + onChangeDescription: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + progress: PropTypes.number, + uploading: PropTypes.bool, +}; diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js new file mode 100644 index 000000000..5addccfb1 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js @@ -0,0 +1,202 @@ +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { + FormattedMessage, + defineMessages, +} from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import IconButton from 'flavours/glitch/components/icon_button'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; +import { assignHandlers } from 'flavours/glitch/util/react_helpers'; + +// Messages. +const messages = defineMessages({ + undo: { + defaultMessage: 'Undo', + id: 'upload_form.undo', + }, + description: { + defaultMessage: 'Describe for the visually impaired', + id: 'upload_form.description', + }, + crop: { + defaultMessage: 'Crop', + id: 'upload_form.focus', + }, +}); + +// Handlers. +const handlers = { + + // On blur, we save the description for the media item. + handleBlur () { + const { + id, + onChangeDescription, + } = this.props; + const { dirtyDescription } = this.state; + + this.setState({ dirtyDescription: null, focused: false }); + + if (id && onChangeDescription && dirtyDescription !== null) { + onChangeDescription(id, dirtyDescription); + } + }, + + // When the value of our description changes, we store it in the + // temp value `dirtyDescription` in our state. + handleChange ({ target: { value } }) { + this.setState({ dirtyDescription: value }); + }, + + // Records focus on the media item. + handleFocus () { + this.setState({ focused: true }); + }, + + // Records the start of a hover over the media item. + handleMouseEnter () { + this.setState({ hovered: true }); + }, + + // Records the end of a hover over the media item. + handleMouseLeave () { + this.setState({ hovered: false }); + }, + + // Removes the media item. + handleRemove () { + const { + id, + onRemove, + } = this.props; + if (id && onRemove) { + onRemove(id); + } + }, + + // Opens the focal point modal. + handleFocalPointClick () { + const { + id, + onOpenFocalPointModal, + } = this.props; + if (id && onOpenFocalPointModal) { + onOpenFocalPointModal(id); + } + }, +}; + +// The component. +export default class ComposerUploadFormItem extends React.PureComponent { + + // Constructor. + constructor (props) { + super(props); + assignHandlers(this, handlers); + this.state = { + hovered: false, + focused: false, + dirtyDescription: null, + }; + } + + // Rendering. + render () { + const { + handleBlur, + handleChange, + handleFocus, + handleMouseEnter, + handleMouseLeave, + handleRemove, + handleFocalPointClick, + } = this.handlers; + const { + intl, + preview, + focusX, + focusY, + mediaType, + } = this.props; + const { + focused, + hovered, + dirtyDescription, + } = this.state; + const active = hovered || focused; + const computedClass = classNames('composer--upload_form--item', { active }); + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + const description = dirtyDescription || (dirtyDescription !== '' && this.props.description) || ''; + + // The result. + return ( + <div + className={computedClass} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + > + <Motion + defaultStyle={{ scale: 0.8 }} + style={{ + scale: spring(1, { + stiffness: 180, + damping: 12, + }), + }} + > + {({ scale }) => ( + <div + style={{ + transform: `scale(${scale})`, + backgroundImage: preview ? `url(${preview})` : null, + backgroundPosition: `${x}% ${y}%` + }} + > + <div className={classNames('composer--upload_form--actions', { active })}> + <button className='icon-button' onClick={handleRemove}> + <i className='fa fa-times' /> <FormattedMessage {...messages.undo} /> + </button> + {mediaType === 'image' && <button className='icon-button' onClick={handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage {...messages.crop} /></button>} + </div> + <label> + <span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span> + <input + maxLength={420} + onBlur={handleBlur} + onChange={handleChange} + onFocus={handleFocus} + placeholder={intl.formatMessage(messages.description)} + type='text' + value={description} + /> + </label> + </div> + )} + </Motion> + </div> + ); + } + +} + +// Props. +ComposerUploadFormItem.propTypes = { + description: PropTypes.string, + id: PropTypes.string, + intl: PropTypes.object.isRequired, + onChangeDescription: PropTypes.func.isRequired, + onOpenFocalPointModal: PropTypes.func.isRequired, + onRemove: PropTypes.func.isRequired, + focusX: PropTypes.number, + focusY: PropTypes.number, + mediaType: PropTypes.string, + preview: PropTypes.string, +}; diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js new file mode 100644 index 000000000..8c4b0eea6 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js @@ -0,0 +1,52 @@ +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import { + defineMessages, + FormattedMessage, +} from 'react-intl'; +import spring from 'react-motion/lib/spring'; + +// Components. +import Icon from 'flavours/glitch/components/icon'; + +// Utils. +import Motion from 'flavours/glitch/util/optional_motion'; + +// Messages. +const messages = defineMessages({ + upload: { + defaultMessage: 'Uploading...', + id: 'upload_progress.label', + }, +}); + +// The component. +export default function ComposerUploadFormProgress ({ progress }) { + + // The result. + return ( + <div className='composer--upload_form--progress'> + <Icon icon='upload' /> + <div className='message'> + <FormattedMessage {...messages.upload} /> + <div className='backdrop'> + <Motion + defaultStyle={{ width: 0 }} + style={{ width: spring(progress) }} + > + {({ width }) => + (<div + className='tracker' + style={{ width: `${width}%` }} + />) + } + </Motion> + </div> + </div> + </div> + ); +} + +// Props. +ComposerUploadFormProgress.propTypes = { progress: PropTypes.number }; diff --git a/app/javascript/flavours/glitch/features/composer/warning/index.js b/app/javascript/flavours/glitch/features/composer/warning/index.js new file mode 100644 index 000000000..c225b50e8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/composer/warning/index.js @@ -0,0 +1,54 @@ +import React from 'react'; +import Motion from 'flavours/glitch/util/optional_motion'; +import spring from 'react-motion/lib/spring'; +import { defineMessages, FormattedMessage } from 'react-intl'; + +// This is the spring used with our motion. +const motionSpring = spring(1, { damping: 35, stiffness: 400 }); + +// Messages. +const messages = defineMessages({ + disclaimer: { + defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.', + id: 'compose_form.lock_disclaimer', + }, + locked: { + defaultMessage: 'locked', + id: 'compose_form.lock_disclaimer.lock', + }, +}); + +// The component. +export default function ComposerWarning () { + return ( + <Motion + defaultStyle={{ + opacity: 0, + scaleX: 0.85, + scaleY: 0.75, + }} + style={{ + opacity: motionSpring, + scaleX: motionSpring, + scaleY: motionSpring, + }} + > + {({ opacity, scaleX, scaleY }) => ( + <div + className='composer--warning' + style={{ + opacity: opacity, + transform: `scale(${scaleX}, ${scaleY})`, + }} + > + <FormattedMessage + {...messages.disclaimer} + values={{ locked: <a href='/settings/profile'><FormattedMessage {...messages.locked} /></a> }} + /> + </div> + )} + </Motion> + ); +} + +ComposerWarning.propTypes = {}; |