Skip to content

Commit 21977ff

Browse files
ClearlyClaireGargron
authored andcommitted
Add emoji suggestions to CW and poll option fields (mastodon#10555)
* Refactor selectComposeSuggestion so that different paths can be updated * Add suggestions in CW field * Add emoji suggestion to poll options * Attempt to fix CSS * Hide suggestions by default They will be enabled if the input has focus
1 parent 9f68bd9 commit 21977ff

10 files changed

Lines changed: 328 additions & 24 deletions

File tree

app/javascript/mastodon/actions/compose.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
383383
};
384384
};
385385

386-
export function selectComposeSuggestion(position, token, suggestion) {
386+
export function selectComposeSuggestion(position, token, suggestion, path) {
387387
return (dispatch, getState) => {
388388
let completion, startPosition;
389389

@@ -405,6 +405,7 @@ export function selectComposeSuggestion(position, token, suggestion) {
405405
position: startPosition,
406406
token,
407407
completion,
408+
path,
408409
});
409410
};
410411
};
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import React from 'react';
2+
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
3+
import AutosuggestEmoji from './autosuggest_emoji';
4+
import ImmutablePropTypes from 'react-immutable-proptypes';
5+
import PropTypes from 'prop-types';
6+
import { isRtl } from '../rtl';
7+
import ImmutablePureComponent from 'react-immutable-pure-component';
8+
import classNames from 'classnames';
9+
import { List as ImmutableList } from 'immutable';
10+
11+
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
12+
let word;
13+
14+
let left = str.slice(0, caretPosition).search(/\S+$/);
15+
let right = str.slice(caretPosition).search(/\s/);
16+
17+
if (right < 0) {
18+
word = str.slice(left);
19+
} else {
20+
word = str.slice(left, right + caretPosition);
21+
}
22+
23+
if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
24+
return [null, null];
25+
}
26+
27+
word = word.trim().toLowerCase();
28+
29+
if (word.length > 0) {
30+
return [left + 1, word];
31+
} else {
32+
return [null, null];
33+
}
34+
};
35+
36+
export default class AutosuggestInput extends ImmutablePureComponent {
37+
38+
static propTypes = {
39+
value: PropTypes.string,
40+
suggestions: ImmutablePropTypes.list,
41+
disabled: PropTypes.bool,
42+
placeholder: PropTypes.string,
43+
onSuggestionSelected: PropTypes.func.isRequired,
44+
onSuggestionsClearRequested: PropTypes.func.isRequired,
45+
onSuggestionsFetchRequested: PropTypes.func.isRequired,
46+
onChange: PropTypes.func.isRequired,
47+
onKeyUp: PropTypes.func,
48+
onKeyDown: PropTypes.func,
49+
autoFocus: PropTypes.bool,
50+
className: PropTypes.string,
51+
id: PropTypes.string,
52+
searchTokens: PropTypes.list,
53+
maxLength: PropTypes.number,
54+
};
55+
56+
static defaultProps = {
57+
autoFocus: true,
58+
searchTokens: ImmutableList(['@', ':', '#']),
59+
};
60+
61+
state = {
62+
suggestionsHidden: true,
63+
focused: false,
64+
selectedSuggestion: 0,
65+
lastToken: null,
66+
tokenStart: 0,
67+
};
68+
69+
onChange = (e) => {
70+
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
71+
72+
if (token !== null && this.state.lastToken !== token) {
73+
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
74+
this.props.onSuggestionsFetchRequested(token);
75+
} else if (token === null) {
76+
this.setState({ lastToken: null });
77+
this.props.onSuggestionsClearRequested();
78+
}
79+
80+
this.props.onChange(e);
81+
}
82+
83+
onKeyDown = (e) => {
84+
const { suggestions, disabled } = this.props;
85+
const { selectedSuggestion, suggestionsHidden } = this.state;
86+
87+
if (disabled) {
88+
e.preventDefault();
89+
return;
90+
}
91+
92+
if (e.which === 229 || e.isComposing) {
93+
// Ignore key events during text composition
94+
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
95+
return;
96+
}
97+
98+
switch(e.key) {
99+
case 'Escape':
100+
if (suggestions.size === 0 || suggestionsHidden) {
101+
document.querySelector('.ui').parentElement.focus();
102+
} else {
103+
e.preventDefault();
104+
this.setState({ suggestionsHidden: true });
105+
}
106+
107+
break;
108+
case 'ArrowDown':
109+
if (suggestions.size > 0 && !suggestionsHidden) {
110+
e.preventDefault();
111+
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
112+
}
113+
114+
break;
115+
case 'ArrowUp':
116+
if (suggestions.size > 0 && !suggestionsHidden) {
117+
e.preventDefault();
118+
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
119+
}
120+
121+
break;
122+
case 'Enter':
123+
case 'Tab':
124+
// Select suggestion
125+
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
126+
e.preventDefault();
127+
e.stopPropagation();
128+
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
129+
}
130+
131+
break;
132+
}
133+
134+
if (e.defaultPrevented || !this.props.onKeyDown) {
135+
return;
136+
}
137+
138+
this.props.onKeyDown(e);
139+
}
140+
141+
onBlur = () => {
142+
this.setState({ suggestionsHidden: true, focused: false });
143+
}
144+
145+
onFocus = () => {
146+
this.setState({ focused: true });
147+
}
148+
149+
onSuggestionClick = (e) => {
150+
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
151+
e.preventDefault();
152+
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
153+
this.input.focus();
154+
}
155+
156+
componentWillReceiveProps (nextProps) {
157+
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
158+
this.setState({ suggestionsHidden: false });
159+
}
160+
}
161+
162+
setInput = (c) => {
163+
this.input = c;
164+
}
165+
166+
renderSuggestion = (suggestion, i) => {
167+
const { selectedSuggestion } = this.state;
168+
let inner, key;
169+
170+
if (typeof suggestion === 'object') {
171+
inner = <AutosuggestEmoji emoji={suggestion} />;
172+
key = suggestion.id;
173+
} else if (suggestion[0] === '#') {
174+
inner = suggestion;
175+
key = suggestion;
176+
} else {
177+
inner = <AutosuggestAccountContainer id={suggestion} />;
178+
key = suggestion;
179+
}
180+
181+
return (
182+
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
183+
{inner}
184+
</div>
185+
);
186+
}
187+
188+
render () {
189+
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
190+
const { suggestionsHidden } = this.state;
191+
const style = { direction: 'ltr' };
192+
193+
if (isRtl(value)) {
194+
style.direction = 'rtl';
195+
}
196+
197+
return (
198+
<div className='autosuggest-input'>
199+
<label>
200+
<span style={{ display: 'none' }}>{placeholder}</span>
201+
202+
<input
203+
type='text'
204+
ref={this.setInput}
205+
disabled={disabled}
206+
placeholder={placeholder}
207+
autoFocus={autoFocus}
208+
value={value}
209+
onChange={this.onChange}
210+
onKeyDown={this.onKeyDown}
211+
onKeyUp={onKeyUp}
212+
onFocus={this.onFocus}
213+
onBlur={this.onBlur}
214+
style={style}
215+
aria-autocomplete='list'
216+
id={id}
217+
className={className}
218+
maxLength={maxLength}
219+
/>
220+
</label>
221+
222+
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
223+
{suggestions.map(this.renderSuggestion)}
224+
</div>
225+
</div>
226+
);
227+
}
228+
229+
}

app/javascript/mastodon/components/autosuggest_textarea.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
5555
};
5656

5757
state = {
58-
suggestionsHidden: false,
58+
suggestionsHidden: true,
59+
focused: false,
5960
selectedSuggestion: 0,
6061
lastToken: null,
6162
tokenStart: 0,
@@ -134,7 +135,11 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
134135
}
135136

136137
onBlur = () => {
137-
this.setState({ suggestionsHidden: true });
138+
this.setState({ suggestionsHidden: true, focused: false });
139+
}
140+
141+
onFocus = () => {
142+
this.setState({ focused: true });
138143
}
139144

140145
onSuggestionClick = (e) => {
@@ -145,7 +150,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
145150
}
146151

147152
componentWillReceiveProps (nextProps) {
148-
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
153+
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
149154
this.setState({ suggestionsHidden: false });
150155
}
151156
}
@@ -207,6 +212,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
207212
onChange={this.onChange}
208213
onKeyDown={this.onKeyDown}
209214
onKeyUp={onKeyUp}
215+
onFocus={this.onFocus}
210216
onBlur={this.onBlur}
211217
onPaste={this.onPaste}
212218
style={style}

app/javascript/mastodon/features/compose/components/compose_form.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
55
import PropTypes from 'prop-types';
66
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
77
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
8+
import AutosuggestInput from '../../../components/autosuggest_input';
89
import PollButtonContainer from '../containers/poll_button_container';
910
import UploadButtonContainer from '../containers/upload_button_container';
1011
import { defineMessages, injectIntl } from 'react-intl';
@@ -102,7 +103,11 @@ class ComposeForm extends ImmutablePureComponent {
102103
}
103104

104105
onSuggestionSelected = (tokenStart, token, value) => {
105-
this.props.onSuggestionSelected(tokenStart, token, value);
106+
this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
107+
}
108+
109+
onSpoilerSuggestionSelected = (tokenStart, token, value) => {
110+
this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
106111
}
107112

108113
handleChangeSpoilerText = (e) => {
@@ -135,7 +140,7 @@ class ComposeForm extends ImmutablePureComponent {
135140
this.autosuggestTextarea.textarea.focus();
136141
} else if (this.props.spoiler !== prevProps.spoiler) {
137142
if (this.props.spoiler) {
138-
this.spoilerText.focus();
143+
this.spoilerText.input.focus();
139144
} else {
140145
this.autosuggestTextarea.textarea.focus();
141146
}
@@ -178,10 +183,21 @@ class ComposeForm extends ImmutablePureComponent {
178183
<ReplyIndicatorContainer />
179184

180185
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}>
181-
<label>
182-
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
183-
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoilerText} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} tabIndex={this.props.spoiler ? 0 : -1} type='text' className='spoiler-input__input' id='cw-spoiler-input' ref={this.setSpoilerText} />
184-
</label>
186+
<AutosuggestInput
187+
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
188+
value={this.props.spoilerText}
189+
onChange={this.handleChangeSpoilerText}
190+
onKeyDown={this.handleKeyDown}
191+
disabled={!this.props.spoiler}
192+
ref={this.setSpoilerText}
193+
suggestions={this.props.suggestions}
194+
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
195+
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
196+
onSuggestionSelected={this.onSpoilerSuggestionSelected}
197+
searchTokens={[':']}
198+
id='cw-spoiler-input'
199+
className='spoiler-input__input'
200+
/>
185201
</div>
186202

187203
<div className='compose-form__autosuggest-wrapper'>

0 commit comments

Comments
 (0)