From 24b6811a6ed5c3449b6fd82dc30e5b988b76df99 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 28 Aug 2018 13:52:18 +0200 Subject: [PATCH] [Glitch] Implement tag auto-completion by history Port 460e380d386367b6809d319859e13d17a6a2acea to glitch-soc --- .../flavours/glitch/actions/compose.js | 74 +++++++++++++++++-- .../flavours/glitch/actions/store.js | 13 +++- .../features/composer/textarea/index.js | 2 +- .../textarea/suggestions/item/index.js | 68 +++++++++-------- .../flavours/glitch/reducers/compose.js | 19 +++++ .../flavours/glitch/util/settings.js | 1 + 6 files changed, 135 insertions(+), 42 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index f6c8086fe1..b9248169f3 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -3,6 +3,7 @@ import { CancelToken } from 'axios'; import { throttle } from 'lodash'; import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light'; import { useEmoji } from './emojis'; +import { tagHistory } from 'flavours/glitch/util/settings'; import resizeImage from 'flavours/glitch/util/resize_image'; import { updateTimeline } from './timelines'; @@ -28,6 +29,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'; @@ -136,6 +140,7 @@ export function submitCompose() { 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), }, }).then(function (response) { + dispatch(insertIntoTagHistory(response.data.tags)); dispatch(submitComposeSuccess({ ...response.data })); // If the response has no data then we can't do anything else. @@ -315,12 +320,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; } }; }; @@ -343,10 +358,15 @@ export function readyComposeSuggestionsAccounts(token, accounts) { export function selectComposeSuggestion(position, token, suggestion) { return (dispatch, getState) => { - const completion = typeof suggestion === 'object' && suggestion.id ? ( - dispatch(useEmoji(suggestion)), - suggestion.native || suggestion.colons - ) : '@' + getState().getIn(['accounts', suggestion, 'acct']); + let completion; + if (typeof suggestion === 'object' && suggestion.id) { + dispatch(useEmoji(suggestion)); + completion = suggestion.native || suggestion.colons; + } else if (suggestion[0] === '#') { + completion = suggestion; + } else { + completion = '@' + getState().getIn(['accounts', suggestion, 'acct']); + } dispatch({ type: COMPOSE_SUGGESTION_SELECT, @@ -357,6 +377,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/flavours/glitch/actions/store.js b/app/javascript/flavours/glitch/actions/store.js index a1db0fdd51..2dd94a9983 100644 --- a/app/javascript/flavours/glitch/actions/store.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js index 51d44a83be..50e46fc784 100644 --- a/app/javascript/flavours/glitch/features/composer/textarea/index.js +++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js @@ -58,7 +58,7 @@ const handlers = { const right = value.slice(selectionStart).search(/[\s\u200B]/); const token = function () { switch (true) { - case left < 0 || !/[@:]/.test(value[left]): + case left < 0 || !/[@:#]/.test(value[left]): return null; case right < 0: return value.slice(left); 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 index f55640bcf9..3316923986 100644 --- a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js +++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js @@ -57,6 +57,42 @@ export default class ComposerTextareaSuggestionsItem extends React.Component { } = this.props; const computedClass = classNames('composer--textarea--suggestions--item', { selected }); + // If the suggestion is an object, then we render an emoji. + // Otherwise, we render a hashtag if it starts with #, or an account. + let inner; + if (typeof suggestion === 'object') { + let url; + if (suggestion.custom) { + url = suggestion.imageUrl; + } else { + const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')]; + if (mapping) { + url = `${assetHost}/emoji/${mapping.filename}.svg`; + } + } + if (url) { + inner = ( +
+ {suggestion.native + {suggestion.colons} +
+ ); + } + } else if (suggestion[0] === '#') { + inner = suggestion; + } else { + inner = ( + + ); + } + // The result. return (
- { // 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 ? ( -
- {suggestion.native - {suggestion.colons} -
- ) : null; - }() : ( - - ) - } + { inner }
); } diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index 5ef5d3f5c5..9c4f110237 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -18,6 +18,8 @@ import { COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_TAGS_UPDATE, + COMPOSE_TAG_HISTORY_UPDATE, COMPOSE_ADVANCED_OPTIONS_CHANGE, COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SPOILERNESS_CHANGE, @@ -77,6 +79,7 @@ const initialState = ImmutableMap({ default_sensitive: false, resetFileKey: Math.floor((Math.random() * 0x10000)), idempotencyKey: null, + tagHistory: ImmutableList(), doodle: ImmutableMap({ fg: 'rgb( 0, 0, 0)', bg: 'rgb(255, 255, 255)', @@ -206,6 +209,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; @@ -360,6 +375,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/flavours/glitch/util/settings.js b/app/javascript/flavours/glitch/util/settings.js index dbd969cb1b..7643a508ea 100644 --- a/app/javascript/flavours/glitch/util/settings.js +++ b/app/javascript/flavours/glitch/util/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');