1 import React
from 'react';
2 import CharacterCounter
from './character_counter';
3 import Button
from '../../../components/button';
4 import ImmutablePropTypes
from 'react-immutable-proptypes';
5 import PropTypes
from 'prop-types';
6 import ReplyIndicatorContainer
from '../containers/reply_indicator_container';
7 import AutosuggestTextarea
from '../../../components/autosuggest_textarea';
8 import { debounce
} from 'lodash';
9 import UploadButtonContainer
from '../containers/upload_button_container';
10 import { defineMessages
, injectIntl
} from 'react-intl';
11 import Collapsable
from '../../../components/collapsable';
12 import SpoilerButtonContainer
from '../containers/spoiler_button_container';
13 import PrivacyDropdownContainer
from '../containers/privacy_dropdown_container';
14 import ComposeAdvancedOptionsContainer
from '../../../../glitch/components/compose/advanced_options/container';
15 import SensitiveButtonContainer
from '../containers/sensitive_button_container';
16 import EmojiPickerDropdown
from './emoji_picker_dropdown';
17 import UploadFormContainer
from '../containers/upload_form_container';
18 import WarningContainer
from '../containers/warning_container';
19 import { isMobile
} from '../../../is_mobile';
20 import ImmutablePureComponent
from 'react-immutable-pure-component';
21 import { length
} from 'stringz';
22 import { countableText
} from '../util/counter';
24 const messages
= defineMessages({
25 placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
26 spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
27 publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
28 publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
32 export default class ComposeForm
extends ImmutablePureComponent
{
35 intl: PropTypes
.object
.isRequired
,
36 text: PropTypes
.string
.isRequired
,
37 suggestion_token: PropTypes
.string
,
38 suggestions: ImmutablePropTypes
.list
,
39 spoiler: PropTypes
.bool
,
40 privacy: PropTypes
.string
,
41 advanced_options: ImmutablePropTypes
.contains({
42 do_not_federate: PropTypes
.bool
,
44 spoiler_text: PropTypes
.string
,
45 focusDate: PropTypes
.instanceOf(Date
),
46 preselectDate: PropTypes
.instanceOf(Date
),
47 is_submitting: PropTypes
.bool
,
48 is_uploading: PropTypes
.bool
,
50 onChange: PropTypes
.func
.isRequired
,
51 onSubmit: PropTypes
.func
.isRequired
,
52 onClearSuggestions: PropTypes
.func
.isRequired
,
53 onFetchSuggestions: PropTypes
.func
.isRequired
,
54 onPrivacyChange: PropTypes
.func
.isRequired
,
55 onSuggestionSelected: PropTypes
.func
.isRequired
,
56 onChangeSpoilerText: PropTypes
.func
.isRequired
,
57 onPaste: PropTypes
.func
.isRequired
,
58 onPickEmoji: PropTypes
.func
.isRequired
,
59 showSearch: PropTypes
.bool
,
60 settings : ImmutablePropTypes
.map
.isRequired
,
61 filesAttached : PropTypes
.bool
,
64 static defaultProps
= {
68 handleChange
= (e
) => {
69 this.props
.onChange(e
.target
.value
);
72 handleKeyDown
= (e
) => {
73 if (e
.keyCode
=== 13 && (e
.ctrlKey
|| e
.metaKey
)) {
78 handleSubmit2
= () => {
79 this.props
.onPrivacyChange(this.props
.settings
.get('side_arm'));
83 handleSubmit
= () => {
84 if (this.props
.text
!== this.autosuggestTextarea
.textarea
.value
) {
85 // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
86 // Update the state to match the current text
87 this.props
.onChange(this.autosuggestTextarea
.textarea
.value
);
90 this.props
.onSubmit();
93 onSuggestionsClearRequested
= () => {
94 this.props
.onClearSuggestions();
97 onSuggestionsFetchRequested
= debounce((token
) => {
98 this.props
.onFetchSuggestions(token
);
99 }, 500, { trailing: true })
101 onLocalSuggestionsFetchRequested
= debounce((token
) => {
102 this.props
.onFetchSuggestions(token
);
103 }, 100, { trailing: true })
105 onSuggestionSelected
= (tokenStart
, token
, value
) => {
106 this._restoreCaret
= null;
107 this.props
.onSuggestionSelected(tokenStart
, token
, value
);
110 handleChangeSpoilerText
= (e
) => {
111 this.props
.onChangeSpoilerText(e
.target
.value
);
114 componentWillReceiveProps (nextProps
) {
115 // If this is the update where we've finished uploading,
116 // save the last caret position so we can restore it below!
117 if (!nextProps
.is_uploading
&& this.props
.is_uploading
) {
118 this._restoreCaret
= this.autosuggestTextarea
.textarea
.selectionStart
;
122 componentDidUpdate (prevProps
) {
123 // This statement does several things:
124 // - If we're beginning a reply, and,
125 // - Replying to zero or one users, places the cursor at the end of the textbox.
126 // - Replying to more than one user, selects any usernames past the first;
127 // this provides a convenient shortcut to drop everyone else from the conversation.
128 // - If we've just finished uploading an image, and have a saved caret position,
129 // restores the cursor to that position after the text changes!
130 if (this.props
.focusDate
!== prevProps
.focusDate
|| (prevProps
.is_uploading
&& !this.props
.is_uploading
&& typeof this._restoreCaret
=== 'number')) {
131 let selectionEnd
, selectionStart
;
133 if (this.props
.preselectDate
!== prevProps
.preselectDate
) {
134 selectionEnd
= this.props
.text
.length
;
135 selectionStart
= this.props
.text
.search(/\s/) + 1;
136 } else if (typeof this._restoreCaret
=== 'number') {
137 selectionStart
= this._restoreCaret
;
138 selectionEnd
= this._restoreCaret
;
140 selectionEnd
= this.props
.text
.length
;
141 selectionStart
= selectionEnd
;
144 this.autosuggestTextarea
.textarea
.setSelectionRange(selectionStart
, selectionEnd
);
145 this.autosuggestTextarea
.textarea
.focus();
146 } else if(prevProps
.is_submitting
&& !this.props
.is_submitting
) {
147 this.autosuggestTextarea
.textarea
.focus();
151 setAutosuggestTextarea
= (c
) => {
152 this.autosuggestTextarea
= c
;
155 handleEmojiPick
= (data
) => {
156 const position
= this.autosuggestTextarea
.textarea
.selectionStart
;
157 const emojiChar
= data
.unicode
.split('-').map(code
=> String
.fromCodePoint(parseInt(code
, 16))).join('');
158 this._restoreCaret
= position
+ emojiChar
.length
+ 1;
159 this.props
.onPickEmoji(position
, data
);
163 const { intl
, onPaste
, showSearch
, filesAttached
} = this.props
;
164 const disabled
= this.props
.is_submitting
;
165 const maybeEye
= (this.props
.advanced_options
&& this.props
.advanced_options
.do_not_federate
) ? ' 👁️' : '';
166 const text
= [this.props
.spoiler_text
, countableText(this.props
.text
), maybeEye
].join('');
168 const secondaryVisibility
= this.props
.settings
.get('side_arm');
169 const isWideView
= this.props
.settings
.get('stretch');
170 let showSideArm
= secondaryVisibility
!== 'none';
172 let publishText
= '';
174 const privacyIcons
= {
177 unlisted: 'unlock-alt',
187 className
={`fa fa-${privacyIcons[this.props.privacy]}`}
189 paddingRight: (filesAttached
|| !isWideView
) ? '0' : '5px',
193 (filesAttached
|| !isWideView
) ? '' :
194 intl
.formatMessage(messages
.publish
)
199 if (this.props
.privacy
=== 'private' || this.props
.privacy
=== 'direct') {
200 publishText
= <span className
='compose-form__publish-private'><i className
='fa fa-lock' /> {intl
.formatMessage(messages
.publish
)}</span
>;
202 publishText
= this.props
.privacy
!== 'unlisted' ? intl
.formatMessage(messages
.publishLoud
, { publish: intl
.formatMessage(messages
.publish
) }) : intl
.formatMessage(messages
.publish
);
209 className
={`fa fa-${privacyIcons[secondaryVisibility]}`}
210 aria
-label
={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`}
214 const submitDisabled = disabled || this.props.is_uploading || length(text) > 500 || (text.length !== 0 && text.trim().length === 0);
217 <div className='compose-form'>
218 <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
219 <div className='spoiler-input'>
221 <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
222 <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' />
229 <ReplyIndicatorContainer />
231 <div className='compose-form__autosuggest-wrapper'>
233 ref={this.setAutosuggestTextarea}
234 placeholder={intl.formatMessage(messages.placeholder)}
236 value={this.props.text}
237 onChange={this.handleChange}
238 suggestions={this.props.suggestions}
239 onKeyDown={this.handleKeyDown}
240 onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
241 onLocalSuggestionsFetchRequested={this.onLocalSuggestionsFetchRequested}
242 onSuggestionsClearRequested={this.onSuggestionsClearRequested}
243 onSuggestionSelected={this.onSuggestionSelected}
245 autoFocus={!showSearch && !isMobile(window.innerWidth)}
248 <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
251 <div className='compose-form__modifiers'>
252 <UploadFormContainer />
255 <div className='compose-form__buttons-wrapper'>
256 <div className='compose-form__buttons'>
257 <UploadButtonContainer />
258 <PrivacyDropdownContainer />
259 <ComposeAdvancedOptionsContainer />
260 <SensitiveButtonContainer />
261 <SpoilerButtonContainer />
264 <div className='compose-form__publish'>
265 <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
266 <div className='compose-form__publish-button-wrapper'>
270 className='compose-form__publish__side-arm'
272 onClick={this.handleSubmit2}
273 disabled={submitDisabled}
278 className='compose-form__publish__primary'
280 onClick={this.handleSubmit}
281 disabled={submitDisabled}