Commit 74cde0b5 authored by wakin-'s avatar wakin- Committed by ikuradon

add hashtag-temp feature

parent a496548e
......@@ -2,7 +2,7 @@ import api from '../api';
import { CancelToken, isCancel } from 'axios';
import { throttle } from 'lodash';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
import { tagHistory } from '../settings';
import { tagHistory, tagTemplate } from '../settings';
import { useEmoji } from './emojis';
import resizeImage from '../utils/resize_image';
import { importFetchedAccounts } from './importer';
......@@ -10,6 +10,7 @@ import { updateTimeline } from './timelines';
import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { defineMessages } from 'react-intl';
import { fromJS } from 'immutable';
let cancelFetchComposeSuggestionsAccounts;
......@@ -37,6 +38,8 @@ export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
export const COMPOSE_TAG_TEMPLATE_UPDATE = 'COMPOSE_TAG_TEMPLATE_UPDATE';
export const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
......@@ -144,13 +147,21 @@ export function directCompose(account, routerHistory) {
export function submitCompose(routerHistory) {
return function (dispatch, getState) {
const status = getState().getIn(['compose', 'text'], '');
let status = getState().getIn(['compose', 'text'], '');
const media = getState().getIn(['compose', 'media_attachments']);
if ((!status || !status.length) && media.size === 0) {
return;
}
const hashtag = getState().getIn(['compose', 'tagTemplate']);
hashtag.map(tag => {
if (tag && tag.get('active') && (tag.get('text') || '').length) {
status = [status, ` #${tag.get('text')}`].join('')
}
});
dispatch(submitComposeRequest());
api(getState).post('/api/v1/statuses', {
......@@ -445,6 +456,86 @@ export function updateTagHistory(tags) {
};
}
export function updateTextTagTemplate(text, index) {
return (dispatch, getState) => {
let active = getState().getIn(['compose', 'tagTemplate', index, 'active']) || true;
if (text.length === 0) {
active = false;
}
updateTagTemplate(text, active, index, dispatch, getState);
};
}
export function addTagTemplate(index) {
return (dispatch, getState) => {
const oldTemplate = getState().getIn(['compose', 'tagTemplate']);
const me = getState().getIn(['meta', 'me']);
if (oldTemplate.size >= 4 || oldTemplate.getIn([index, 'text']).length === 0) {
return;
}
const tags = oldTemplate.push(fromJS({text: '', active: false}));
dispatch({
type: COMPOSE_TAG_TEMPLATE_UPDATE,
tags,
});
tagTemplate.set(me, tags);
}
}
export function delTagTemplate(index) {
return (dispatch, getState) => {
if (index === 0) {
return;
}
const oldTemplate = getState().getIn(['compose', 'tagTemplate']);
const me = getState().getIn(['meta', 'me']);
const tags = oldTemplate.delete(index);
dispatch({
type: COMPOSE_TAG_TEMPLATE_UPDATE,
tags,
});
tagTemplate.set(me, tags);
}
}
export function enableTagTemplate(index) {
return (dispatch, getState) => {
const text = getState().getIn(['compose', 'tagTemplate', index, 'text']);
if (text.length > 0) {
updateTagTemplate(text, true, index, dispatch, getState);
}
};
}
export function disableTagTemplate(index) {
return (dispatch, getState) => {
const text = getState().getIn(['compose', 'tagTemplate', index, 'text']);
updateTagTemplate(text, false, index, dispatch, getState);
};
}
function updateTagTemplate(text, active, index, dispatch, getState) {
const oldTemplate = getState().getIn(['compose', 'tagTemplate']);
const me = getState().getIn(['meta', 'me']);
const tags = oldTemplate.setIn([index], fromJS({text: text, active: active}));
dispatch({
type: COMPOSE_TAG_TEMPLATE_UPDATE,
tags,
});
tagTemplate.set(me, tags);
}
export function hydrateCompose() {
return (dispatch, getState) => {
const me = getState().getIn(['meta', 'me']);
......
......@@ -3,6 +3,7 @@ import CharacterCounter from './character_counter';
import Button from '../../../components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Immutable from 'immutable';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import QuoteIndicatorContainer from '../containers/quote_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
......@@ -13,6 +14,7 @@ import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import PollFormContainer from '../containers/poll_form_container';
import HashtagTempContainer from '../containers/hashtag_temp_container';
import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
import { isMobile } from '../../../is_mobile';
......@@ -26,6 +28,7 @@ const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u20
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
hashtag_temp_placeholder: { id: 'compose_form.hashtag_temp_placeholder', defaultMessage: 'Append tag' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
});
......@@ -60,6 +63,7 @@ class ComposeForm extends ImmutablePureComponent {
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
tagTemplate: ImmutablePropTypes.list,
};
static defaultProps = {
......@@ -70,6 +74,8 @@ class ComposeForm extends ImmutablePureComponent {
this.props.onChange(e.target.value);
}
state = { tagSuggestionFrom: null }
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
......@@ -96,14 +102,22 @@ class ComposeForm extends ImmutablePureComponent {
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
this.setState({ tagSuggestionFrom: null });
}
onSuggestionsFetchRequested = (token) => {
this.setState({ tagSuggestionFrom: 'autosuggested-textarea' });
this.props.onFetchSuggestions(token);
}
onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value);
this.setState({ tagSuggestionFrom: null });
}
onHashTagSuggestionsFetchRequested = (token, index) => {
this.setState({ tagSuggestionFrom: 'hashtag-temp-'+index.toString() });
this.props.onFetchSuggestions(`#${token}`);
}
handleChangeSpoilerText = (e) => {
......@@ -160,9 +174,12 @@ class ComposeForm extends ImmutablePureComponent {
}
render () {
const { intl, onPaste, showSearch, anyMedia } = this.props;
const { intl, onPaste, showSearch, anyMedia, tagTemplate } = this.props;
const { tagSuggestionFrom } = this.state;
const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
const activeTag = tagTemplate.map(tag => tag && tag.get('active') ? tag.get('text') || '' : '');
const preTag = activeTag.map(tag => tag.length > 0 ? ' #' : '');
const text = [this.props.spoilerText, countableText(this.props.text), preTag.join(''), activeTag.join('')].join('');
const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > 500 || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
let publishText = '';
......@@ -193,7 +210,7 @@ class ComposeForm extends ImmutablePureComponent {
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
suggestions={tagSuggestionFrom === 'autosuggested-textarea' ? this.props.suggestions : Immutable.List()}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
......@@ -208,7 +225,13 @@ class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__modifiers'>
<UploadFormContainer />
<PollFormContainer />
</div>
<HashtagTempContainer
onSuggestionsFetchRequested={this.onHashTagSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
suggestions={this.props.suggestions}
tagSuggestionFrom={tagSuggestionFrom}
/>
</div>
<div className='compose-form__buttons-wrapper'>
<div className='compose-form__buttons'>
......
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import IconButton from '../../../components/icon_button';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import Immutable from 'immutable';
const messages = defineMessages({
hashtag_temp_placeholder: { id: 'compose_form.hashtag_temp_placeholder', defaultMessage: 'Append tag (Enter to add more)' },
});
const getHashtagWord = (value) => {
if (!value) {
return '';
}
const trimmed = value.trim();
return (trimmed[0] === '#') ? trimmed.slice(1) : trimmed;
};
const iconStyle = {
height: null,
lineHeight: '27px',
};
@injectIntl
class Form extends React.PureComponent {
static propTypes = {
value: PropTypes.string,
active: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
suggestions: ImmutablePropTypes.list,
onChangeTagTemplate: PropTypes.func,
onAddTagTemplate: PropTypes.func,
onDeleteTagTemplate: PropTypes.func,
onEnableTagTemplate: PropTypes.func,
onDisableTagTemplate: PropTypes.func,
index: PropTypes.number
};
state = {
suggestionsHidden: false,
selectedSuggestion: 0,
lastToken: null,
};
onChange = (e) => {
const { value } = e.target;
const hashtag = getHashtagWord(value);
this.props.onChangeTagTemplate(hashtag, this.props.index);
if (hashtag) {
this.setState({ value, lastToken: hashtag });
this.props.onSuggestionsFetchRequested(hashtag, this.props.index);
} else {
this.setState({ value, lastToken: null });
this.props.onSuggestionsClearRequested();
}
}
onKeyDown = (e) => {
const { value, suggestions } = this.props;
const { suggestionsHidden, selectedSuggestion } = this.state;
switch(e.key) {
case 'Escape':
if (!suggestionsHidden) {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion -1, 0) });
}
break;
case 'Enter':
this.props.onAddTagTemplate(this.props.index);
case 'Tab':
// Note: Ignore the event of Confirm Conversion of IME
if (e.keyCode === 229) {
break;
}
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.insertHashtag(suggestions.get(selectedSuggestion));
} else if (e.keyCode === 13) {
e.preventDefault();
this.insertHashtag(value);
}
break;
case 'Backspace':
if (value.length === 0) {
e.preventDefault();
this.props.onDeleteTagTemplate(this.props.index);
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
}
onBlur = () => {
this.setState({ suggestionsHidden: true });
}
insertHashtag = (value) => {
const hashtag = getHashtagWord(value);
this.props.onChangeTagTemplate(hashtag, this.props.index);
if (hashtag) {
this.props.onSuggestionsClearRequested();
this.setState({
value: hashtag,
suggestionsHidden: true,
selectedSuggestion: 0,
lastToken: null,
});
}
}
onSuggestionClick = (e) => {
e.preventDefault();
const { suggestions } = this.props;
const index = e.currentTarget.getAttribute('data-index');
this.insertHashtag(suggestions.get(index));
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions &&
nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
this.setState({ suggestionsHidden: false });
}
}
renderHashTagSuggestion = (tag, i) => {
const { selectedSuggestion } = this.state;
return (
<div
role='button'
tabIndex='0'
key={tag}
className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
data-index={i}
onMouseDown={this.onSuggestionClick}
>
{tag}
</div>
);
}
handleClick = (e) => {
e.preventDefault();
if (this.props.active) {
this.props.onDisableTagTemplate(this.props.index);
} else {
this.props.onEnableTagTemplate(this.props.index);
}
}
handleRemove = () => {
this.props.onDeleteTagTemplate(this.props.index);
}
render () {
const { value, active, suggestions, placeholder, onKeyUp, index } = this.props;
const { suggestionsHidden } = this.state;
return (
<div className='hashtag-temp'>
<IconButton icon='hashtag' title={''} onClick={this.handleClick} className='hashtag-temp__button-icon' active={active} size={15} inverted style={iconStyle} />
<input
className='hastag-temp__input'
placeholder={placeholder}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onBlur={this.onBlur}
/>
<div className='hashtag-temp__cancel'>
<IconButton disabled={index <= 0} title={''} icon='times' onClick={this.handleRemove} />
</div>
<div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
{suggestions.map(this.renderHashTagSuggestion)}
</div>
</div>
);
}
}
export default
@injectIntl
class HashtagTemp extends ImmutablePureComponent {
static propTypes = {
tagTemplate: ImmutablePropTypes.list,
onChangeTagTemplate: PropTypes.func,
onAddTagTemplate: PropTypes.func,
onDeleteTagTemplate: PropTypes.func,
onEnableTagTemplate: PropTypes.func,
onDisableTagTemplate: PropTypes.func,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
tagSuggestionFrom: PropTypes.string,
intl: PropTypes.object.isRequired,
};
render () {
const {
tagTemplate,
tagSuggestionFrom,
suggestions,
intl,
} = this.props;
if (!tagTemplate) {
return null;
}
return (
<div ref={this.setRef}>
{tagTemplate.map((tag, i) => <Form
key={i}
value={tag.get('text')}
active={tag.get('active')}
placeholder={intl.formatMessage(messages.hashtag_temp_placeholder)}
onSuggestionsClearRequested={this.props.onSuggestionsClearRequested}
onSuggestionsFetchRequested={this.props.onSuggestionsFetchRequested}
suggestions={tagSuggestionFrom === 'hashtag-temp-'+i.toString() ? suggestions : Immutable.List()}
onChangeTagTemplate={this.props.onChangeTagTemplate}
onAddTagTemplate={this.props.onAddTagTemplate}
onDeleteTagTemplate={this.props.onDeleteTagTemplate}
onEnableTagTemplate={this.props.onEnableTagTemplate}
onDisableTagTemplate={this.props.onDisableTagTemplate}
index={i}
/>)}
</div>
);
}
}
\ No newline at end of file
......@@ -25,6 +25,7 @@ const mapStateToProps = state => ({
isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
tagTemplate : state.getIn(['compose', 'tagTemplate']),
});
const mapDispatchToProps = (dispatch) => ({
......@@ -60,7 +61,6 @@ const mapDispatchToProps = (dispatch) => ({
onPickEmoji (position, data, needsSpace) {
dispatch(insertEmojiCompose(position, data, needsSpace));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
import { connect } from 'react-redux';
import HashtagTemp from '../components/hashtag_temp';
import {
updateTextTagTemplate,
addTagTemplate,
delTagTemplate,
enableTagTemplate,
disableTagTemplate
} from '../../../actions/compose';
const mapStateToProps = state => ({
tagTemplate : state.getIn(['compose', 'tagTemplate']),
});
const mapDispatchToProps = dispatch => ({
onChangeTagTemplate (tag, index) {
dispatch(updateTextTagTemplate(tag, index));
},
onAddTagTemplate (index) {
dispatch(addTagTemplate(index));
},
onDeleteTagTemplate (index) {
dispatch(delTagTemplate(index));
},
onEnableTagTemplate (index) {
dispatch(enableTagTemplate(index));
},
onDisableTagTemplate (index) {
dispatch(disableTagTemplate(index));
}
});
export default connect(mapStateToProps, mapDispatchToProps)(HashtagTemp);
......@@ -9,7 +9,7 @@ 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'])),
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && (APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])) || ((state.getIn(['compose', 'tagTemplate']) || []).map(tag => tag && tag.get('active') ? (tag.get('text') || '') : '').join('').length > 0)),
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
});
......
......@@ -68,6 +68,7 @@
"compose_form.direct_message_warning": "このトゥートはメンションされた人にのみ送信されます。",
"compose_form.direct_message_warning_learn_more": "もっと詳しく",
"compose_form.hashtag_warning": "このトゥートは公開設定ではないのでハッシュタグの一覧に表示されません。公開トゥートだけがハッシュタグで検索できます。",
"compose_form.hashtag_temp_placeholder": "タグを入力(Enterで追加)",
"compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。",
"compose_form.lock_disclaimer.lock": "承認制",
"compose_form.placeholder": "今なにしてる?",
......
......@@ -21,6 +21,7 @@ import {
COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_TAG_TEMPLATE_UPDATE,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_SPOILER_TEXT_CHANGE,
......@@ -45,6 +46,7 @@ import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrde
import uuid from '../uuid';
import { me } from '../initial_state';
import { unescapeHTML } from '../utils/html';
import { tagTemplate } from '../settings';
const initialState = ImmutableMap({
mounted: 0,
......@@ -73,6 +75,7 @@ const initialState = ImmutableMap({
resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null,
tagHistory: ImmutableList(),
tagTemplate: ImmutableList(),
});
const initialPoll = ImmutableMap({
......@@ -81,6 +84,17 @@ const initialPoll = ImmutableMap({
multiple: false,
});
const initialTagTemp = ImmutableList([
ImmutableMap({
text: '',
active: false
})
]);
function getTagTemplate() {
return fromJS(tagTemplate.get(me)) || initialTagTemp;
}
function statusToTextMentions(state, status) {
let set = ImmutableOrderedSet([]);
......@@ -105,6 +119,7 @@ function clearAll(state) {
map.update('media_attachments', list => list.clear());
map.set('poll', null);
map.set('idempotencyKey', uuid());
map.set('tagTemplate', getTagTemplate());
});
};
......@@ -303,6 +318,7 @@ export default function compose(state = initialState, action) {
map.set('privacy', state.get('default_privacy'));
map.set('poll', null);
map.set('idempotencyKey', uuid());
map.set('tagTemplate', getTagTemplate());
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
......@@ -349,6 +365,8 @@ export default function compose(state = initialState, action) {
return updateSuggestionTags(state, action.token);
case COMPOSE_TAG_HISTORY_UPDATE:
return state.set('tagHistory', fromJS(action.tags));
case COMPOSE_TAG_TEMPLATE_UPDATE:
return state.set('tagTemplate', action.tags);
case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null);
......@@ -378,6 +396,7 @@ export default function compose(state = initialState, action) {
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('tagTemplate', getTagTemplate());
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);
......
......@@ -45,3 +45,4 @@ export default class Settings {
export const pushNotificationsSetting = new Settings('mastodon_push_notification_data');
export const tagHistory = new Settings('mastodon_tag_history');
export const tagTemplate = new Settings('mastodon_tag_template_list');
\ No newline at end of file
......@@ -17,6 +17,7 @@
@import 'mastodon/boost';
@import 'mastodon/components';
@import 'mastodon/polls';
@import 'mastodon/hashtag_temp';
@import 'mastodon/introduction';
@import 'mastodon/modal';
@import 'mastodon/emoji_picker';
......
......@@ -335,6 +335,7 @@
}
.autosuggest-textarea__textarea,
.hastag-temp__input,
.spoiler-input__input {
display: block;
box-sizing: border-box;
......
.compose-form .hashtag-temp {
position: relative;
}
.hashtag-temp {
display: flex;
align-items: center;
border-top: 1px solid rgba($ui-base-color, 0.1);
font-size: 12px;
.hastag-temp__input {
padding-left: 3px;
font-size: 12px;
border-radius: 0 0 4px;
width: calc(100% - (23px + 30px));
}
.hashtag-temp__button-icon {