From 460e380d386367b6809d319859e13d17a6a2acea Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Mon, 5 Mar 2018 04:27:25 +0900 Subject: [PATCH] Implement tag auto-completion by history (#6621) This is a functionality similar to one implemented in Pawoo: https://github.com/pixiv/mastodon/commit/21a3c70f8083b1347d2b8420ed7001b78c2c9620 --- app/javascript/mastodon/actions/compose.js | 64 ++++++++++++++++++- app/javascript/mastodon/actions/store.js | 13 ++-- .../components/autosuggest_textarea.js | 5 +- app/javascript/mastodon/reducers/compose.js | 19 ++++++ app/javascript/mastodon/settings.js | 1 + 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 1732ff189..0fe480022 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,6 +1,7 @@ import api from '../api'; import { throttle } from 'lodash'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; +import { tagHistory } from '../settings'; import { useEmoji } from './emojis'; import { @@ -27,6 +28,9 @@ export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO'; export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; +export const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE'; + +export const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; @@ -111,6 +115,7 @@ export function submitCompose() { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, }).then(function (response) { + dispatch(insertIntoTagHistory(response.data.tags)); dispatch(submitComposeSuccess({ ...response.data })); // To make the app more responsive, immediately get the status into the columns @@ -273,12 +278,22 @@ const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => { dispatch(readyComposeSuggestionsEmojis(token, results)); }; +const fetchComposeSuggestionsTags = (dispatch, getState, token) => { + dispatch(updateSuggestionTags(token)); +}; + export function fetchComposeSuggestions(token) { return (dispatch, getState) => { - if (token[0] === ':') { + switch (token[0]) { + case ':': fetchComposeSuggestionsEmojis(dispatch, getState, token); - } else { + break; + case '#': + fetchComposeSuggestionsTags(dispatch, getState, token); + break; + default: fetchComposeSuggestionsAccounts(dispatch, getState, token); + break; } }; }; @@ -308,6 +323,9 @@ export function selectComposeSuggestion(position, token, suggestion) { startPosition = position - 1; dispatch(useEmoji(suggestion)); + } else if (suggestion[0] === '#') { + completion = suggestion; + startPosition = position - 1; } else { completion = getState().getIn(['accounts', suggestion, 'acct']); startPosition = position; @@ -322,6 +340,48 @@ export function selectComposeSuggestion(position, token, suggestion) { }; }; +export function updateSuggestionTags(token) { + return { + type: COMPOSE_SUGGESTION_TAGS_UPDATE, + token, + }; +} + +export function updateTagHistory(tags) { + return { + type: COMPOSE_TAG_HISTORY_UPDATE, + tags, + }; +} + +export function hydrateCompose() { + return (dispatch, getState) => { + const me = getState().getIn(['meta', 'me']); + const history = tagHistory.get(me); + + if (history !== null) { + dispatch(updateTagHistory(history)); + } + }; +} + +function insertIntoTagHistory(tags) { + return (dispatch, getState) => { + const state = getState(); + const oldHistory = state.getIn(['compose', 'tagHistory']); + const me = state.getIn(['meta', 'me']); + const names = tags.map(({ name }) => name); + const intersectedOldHistory = oldHistory.filter(name => !names.includes(name)); + + names.push(...intersectedOldHistory.toJS()); + + const newHistory = names.slice(0, 1000); + + tagHistory.set(me, newHistory); + dispatch(updateTagHistory(newHistory)); + }; +} + export function mountCompose() { return { type: COMPOSE_MOUNT, diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index a1db0fdd5..2dd94a998 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -1,4 +1,5 @@ import { Iterable, fromJS } from 'immutable'; +import { hydrateCompose } from './compose'; export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; @@ -8,10 +9,14 @@ const convertState = rawState => Iterable.isIndexed(v) ? v.toList() : v.toMap()); export function hydrateStore(rawState) { - const state = convertState(rawState); + return dispatch => { + const state = convertState(rawState); - return { - type: STORE_HYDRATE, - state, + dispatch({ + type: STORE_HYDRATE, + state, + }); + + dispatch(hydrateCompose()); }; }; diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 6a16e2fc7..34904194f 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -20,7 +20,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => { word = str.slice(left, right + caretPosition); } - if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { + if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) { return [null, null]; } @@ -170,6 +170,9 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { if (typeof suggestion === 'object') { inner = ; key = suggestion.id; + } else if (suggestion[0] === '#') { + inner = suggestion; + key = suggestion; } else { inner = ; key = suggestion; diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 1358fb4aa..dc88390df 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -16,6 +16,8 @@ import { COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_TAGS_UPDATE, + COMPOSE_TAG_HISTORY_UPDATE, COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE, @@ -54,6 +56,7 @@ const initialState = ImmutableMap({ default_sensitive: false, resetFileKey: Math.floor((Math.random() * 0x10000)), idempotencyKey: null, + tagHistory: ImmutableList(), }); function statusToTextMentions(state, status) { @@ -122,6 +125,18 @@ const insertSuggestion = (state, position, token, completion) => { }); }; +const updateSuggestionTags = (state, token) => { + const prefix = token.slice(1); + + return state.merge({ + suggestions: state.get('tagHistory') + .filter(tag => tag.startsWith(prefix)) + .slice(0, 4) + .map(tag => '#' + tag), + suggestion_token: token, + }); +}; + const insertEmoji = (state, position, emojiData) => { const emoji = emojiData.native; @@ -252,6 +267,10 @@ export default function compose(state = initialState, action) { return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token); case COMPOSE_SUGGESTION_SELECT: return insertSuggestion(state, action.position, action.token, action.completion); + case COMPOSE_SUGGESTION_TAGS_UPDATE: + return updateSuggestionTags(state, action.token); + case COMPOSE_TAG_HISTORY_UPDATE: + return state.set('tagHistory', fromJS(action.tags)); case TIMELINE_DELETE: if (action.id === state.get('in_reply_to')) { return state.set('in_reply_to', null); diff --git a/app/javascript/mastodon/settings.js b/app/javascript/mastodon/settings.js index dbd969cb1..7643a508e 100644 --- a/app/javascript/mastodon/settings.js +++ b/app/javascript/mastodon/settings.js @@ -44,3 +44,4 @@ export default class Settings { } export const pushNotificationsSetting = new Settings('mastodon_push_notification_data'); +export const tagHistory = new Settings('mastodon_tag_history');