[Glitch] Implement tag auto-completion by history

Port 460e380d38 to glitch-soc
pull/685/head
Thibaut Girka 2018-08-28 13:52:18 +02:00 committed by ThibG
parent e3246cd13b
commit 24b6811a6e
6 changed files with 135 additions and 42 deletions

View File

@ -3,6 +3,7 @@ import { CancelToken } from 'axios';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light'; import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
import { useEmoji } from './emojis'; import { useEmoji } from './emojis';
import { tagHistory } from 'flavours/glitch/util/settings';
import resizeImage from 'flavours/glitch/util/resize_image'; import resizeImage from 'flavours/glitch/util/resize_image';
import { updateTimeline } from './timelines'; 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_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY'; export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; 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_MOUNT = 'COMPOSE_MOUNT';
export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
@ -136,6 +140,7 @@ export function submitCompose() {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
}, },
}).then(function (response) { }).then(function (response) {
dispatch(insertIntoTagHistory(response.data.tags));
dispatch(submitComposeSuccess({ ...response.data })); dispatch(submitComposeSuccess({ ...response.data }));
// If the response has no data then we can't do anything else. // 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)); dispatch(readyComposeSuggestionsEmojis(token, results));
}; };
const fetchComposeSuggestionsTags = (dispatch, getState, token) => {
dispatch(updateSuggestionTags(token));
};
export function fetchComposeSuggestions(token) { export function fetchComposeSuggestions(token) {
return (dispatch, getState) => { return (dispatch, getState) => {
if (token[0] === ':') { switch (token[0]) {
case ':':
fetchComposeSuggestionsEmojis(dispatch, getState, token); fetchComposeSuggestionsEmojis(dispatch, getState, token);
} else { break;
case '#':
fetchComposeSuggestionsTags(dispatch, getState, token);
break;
default:
fetchComposeSuggestionsAccounts(dispatch, getState, token); fetchComposeSuggestionsAccounts(dispatch, getState, token);
break;
} }
}; };
}; };
@ -343,10 +358,15 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
export function selectComposeSuggestion(position, token, suggestion) { export function selectComposeSuggestion(position, token, suggestion) {
return (dispatch, getState) => { return (dispatch, getState) => {
const completion = typeof suggestion === 'object' && suggestion.id ? ( let completion;
dispatch(useEmoji(suggestion)), if (typeof suggestion === 'object' && suggestion.id) {
suggestion.native || suggestion.colons dispatch(useEmoji(suggestion));
) : '@' + getState().getIn(['accounts', suggestion, 'acct']); completion = suggestion.native || suggestion.colons;
} else if (suggestion[0] === '#') {
completion = suggestion;
} else {
completion = '@' + getState().getIn(['accounts', suggestion, 'acct']);
}
dispatch({ dispatch({
type: COMPOSE_SUGGESTION_SELECT, 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() { export function mountCompose() {
return { return {
type: COMPOSE_MOUNT, type: COMPOSE_MOUNT,

View File

@ -1,4 +1,5 @@
import { Iterable, fromJS } from 'immutable'; import { Iterable, fromJS } from 'immutable';
import { hydrateCompose } from './compose';
export const STORE_HYDRATE = 'STORE_HYDRATE'; export const STORE_HYDRATE = 'STORE_HYDRATE';
export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY';
@ -8,10 +9,14 @@ const convertState = rawState =>
Iterable.isIndexed(v) ? v.toList() : v.toMap()); Iterable.isIndexed(v) ? v.toList() : v.toMap());
export function hydrateStore(rawState) { export function hydrateStore(rawState) {
const state = convertState(rawState); return dispatch => {
const state = convertState(rawState);
return { dispatch({
type: STORE_HYDRATE, type: STORE_HYDRATE,
state, state,
});
dispatch(hydrateCompose());
}; };
}; };

View File

@ -58,7 +58,7 @@ const handlers = {
const right = value.slice(selectionStart).search(/[\s\u200B]/); const right = value.slice(selectionStart).search(/[\s\u200B]/);
const token = function () { const token = function () {
switch (true) { switch (true) {
case left < 0 || !/[@:]/.test(value[left]): case left < 0 || !/[@:#]/.test(value[left]):
return null; return null;
case right < 0: case right < 0:
return value.slice(left); return value.slice(left);

View File

@ -57,6 +57,42 @@ export default class ComposerTextareaSuggestionsItem extends React.Component {
} = this.props; } = this.props;
const computedClass = classNames('composer--textarea--suggestions--item', { selected }); 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 = (
<div className='emoji'>
<img
alt={suggestion.native || suggestion.colons}
className='emojione'
src={url}
/>
{suggestion.colons}
</div>
);
}
} else if (suggestion[0] === '#') {
inner = suggestion;
} else {
inner = (
<AccountContainer
id={suggestion}
small
/>
);
}
// The result. // The result.
return ( return (
<div <div
@ -66,37 +102,7 @@ export default class ComposerTextareaSuggestionsItem extends React.Component {
role='button' role='button'
tabIndex='0' tabIndex='0'
> >
{ // If the suggestion is an object, then we render an emoji. { inner }
// 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 ? (
<div className='emoji'>
<img
alt={suggestion.native || suggestion.colons}
className='emojione'
src={url}
/>
{suggestion.colons}
</div>
) : null;
}() : (
<AccountContainer
id={suggestion}
small
/>
)
}
</div> </div>
); );
} }

View File

@ -18,6 +18,8 @@ import {
COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_ADVANCED_OPTIONS_CHANGE, COMPOSE_ADVANCED_OPTIONS_CHANGE,
COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILERNESS_CHANGE,
@ -77,6 +79,7 @@ const initialState = ImmutableMap({
default_sensitive: false, default_sensitive: false,
resetFileKey: Math.floor((Math.random() * 0x10000)), resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null, idempotencyKey: null,
tagHistory: ImmutableList(),
doodle: ImmutableMap({ doodle: ImmutableMap({
fg: 'rgb( 0, 0, 0)', fg: 'rgb( 0, 0, 0)',
bg: 'rgb(255, 255, 255)', 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 insertEmoji = (state, position, emojiData) => {
const emoji = emojiData.native; 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); return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT: case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion); 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: case TIMELINE_DELETE:
if (action.id === state.get('in_reply_to')) { if (action.id === state.get('in_reply_to')) {
return state.set('in_reply_to', null); return state.set('in_reply_to', null);

View File

@ -44,3 +44,4 @@ export default class Settings {
} }
export const pushNotificationsSetting = new Settings('mastodon_push_notification_data'); export const pushNotificationsSetting = new Settings('mastodon_push_notification_data');
export const tagHistory = new Settings('mastodon_tag_history');