Add emoji autosuggest (#5053)
* Add emoji autosuggest Some credit goes to glitch-soc/mastodon#149 * Remove server-side shortcode->unicode conversion * Insert shortcode when suggestion is custom emoji * Remove remnant of server-side emojis * Update style of autosuggestions * Fix wrong emoji filenames generated in autosuggest item * Do not lazy load emoji picker, as that no longer works * Fix custom emoji autosuggest * Fix multiple "Custom" categories getting added to emoji index, only add oncegh/stable
parent
66126f3021
commit
1e02ba111a
|
@ -1,24 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
module EmojiHelper
|
|
||||||
def emojify(text)
|
|
||||||
return text if text.blank?
|
|
||||||
|
|
||||||
text.gsub(emoji_pattern) do |match|
|
|
||||||
emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs
|
|
||||||
|
|
||||||
if emoji
|
|
||||||
emoji
|
|
||||||
else
|
|
||||||
match
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def emoji_pattern
|
|
||||||
@emoji_pattern ||=
|
|
||||||
/(?<=[^[:alnum:]:]|\n|^)
|
|
||||||
(#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')})
|
|
||||||
(?=[^[:alnum:]:]|$)/x
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,4 +1,5 @@
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { emojiIndex } from 'emoji-mart';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
updateTimeline,
|
updateTimeline,
|
||||||
|
@ -210,19 +211,33 @@ export function clearComposeSuggestions() {
|
||||||
|
|
||||||
export function fetchComposeSuggestions(token) {
|
export function fetchComposeSuggestions(token) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
|
if (token[0] === ':') {
|
||||||
|
const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 });
|
||||||
|
dispatch(readyComposeSuggestionsEmojis(token, results));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
api(getState).get('/api/v1/accounts/search', {
|
api(getState).get('/api/v1/accounts/search', {
|
||||||
params: {
|
params: {
|
||||||
q: token,
|
q: token.slice(1),
|
||||||
resolve: false,
|
resolve: false,
|
||||||
limit: 4,
|
limit: 4,
|
||||||
},
|
},
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
dispatch(readyComposeSuggestions(token, response.data));
|
dispatch(readyComposeSuggestionsAccounts(token, response.data));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function readyComposeSuggestions(token, accounts) {
|
export function readyComposeSuggestionsEmojis(token, emojis) {
|
||||||
|
return {
|
||||||
|
type: COMPOSE_SUGGESTIONS_READY,
|
||||||
|
token,
|
||||||
|
emojis,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readyComposeSuggestionsAccounts(token, accounts) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_SUGGESTIONS_READY,
|
type: COMPOSE_SUGGESTIONS_READY,
|
||||||
token,
|
token,
|
||||||
|
@ -230,13 +245,21 @@ export function readyComposeSuggestions(token, accounts) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function selectComposeSuggestion(position, token, accountId) {
|
export function selectComposeSuggestion(position, token, suggestion) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const completion = getState().getIn(['accounts', accountId, 'acct']);
|
let completion, startPosition;
|
||||||
|
|
||||||
|
if (typeof suggestion === 'object' && suggestion.id) {
|
||||||
|
completion = suggestion.native || suggestion.colons;
|
||||||
|
startPosition = position - 1;
|
||||||
|
} else {
|
||||||
|
completion = getState().getIn(['accounts', suggestion, 'acct']);
|
||||||
|
startPosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: COMPOSE_SUGGESTION_SELECT,
|
type: COMPOSE_SUGGESTION_SELECT,
|
||||||
position,
|
position: startPosition,
|
||||||
token,
|
token,
|
||||||
completion,
|
completion,
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { unicodeMapping } from '../emojione_light';
|
||||||
|
|
||||||
|
const assetHost = process.env.CDN_HOST || '';
|
||||||
|
|
||||||
|
export default class AutosuggestEmoji extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
emoji: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { emoji } = this.props;
|
||||||
|
let url;
|
||||||
|
|
||||||
|
if (emoji.custom) {
|
||||||
|
url = emoji.imageUrl;
|
||||||
|
} else {
|
||||||
|
const [ filename ] = unicodeMapping[emoji.native];
|
||||||
|
url = `${assetHost}/emoji/${filename}.svg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='autosuggest-emoji'>
|
||||||
|
<img
|
||||||
|
className='emojione'
|
||||||
|
src={url}
|
||||||
|
alt={emoji.native || emoji.colons}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{emoji.colons}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,10 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
|
import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { isRtl } from '../rtl';
|
import { isRtl } from '../rtl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Textarea from 'react-textarea-autosize';
|
import Textarea from 'react-textarea-autosize';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||||
let word;
|
let word;
|
||||||
|
@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||||
word = str.slice(left, right + caretPosition);
|
word = str.slice(left, right + caretPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!word || word.trim().length < 2 || word[0] !== '@') {
|
if (!word || word.trim().length < 2 || ['@', ':'].indexOf(word[0]) === -1) {
|
||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
word = word.trim().toLowerCase().slice(1);
|
word = word.trim().toLowerCase();
|
||||||
|
|
||||||
if (word.length > 0) {
|
if (word.length > 0) {
|
||||||
return [left + 1, word];
|
return [left + 1, word];
|
||||||
|
@ -128,7 +130,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuggestionClick = (e) => {
|
onSuggestionClick = (e) => {
|
||||||
const suggestion = e.currentTarget.getAttribute('data-index');
|
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||||
this.textarea.focus();
|
this.textarea.focus();
|
||||||
|
@ -151,9 +153,28 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderSuggestion = (suggestion, i) => {
|
||||||
|
const { selectedSuggestion } = this.state;
|
||||||
|
let inner, key;
|
||||||
|
|
||||||
|
if (typeof suggestion === 'object') {
|
||||||
|
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||||
|
key = suggestion.id;
|
||||||
|
} else {
|
||||||
|
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||||
|
key = suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
|
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
|
||||||
const { suggestionsHidden, selectedSuggestion } = this.state;
|
const { suggestionsHidden } = this.state;
|
||||||
const style = { direction: 'ltr' };
|
const style = { direction: 'ltr' };
|
||||||
|
|
||||||
if (isRtl(value)) {
|
if (isRtl(value)) {
|
||||||
|
@ -164,6 +185,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
<div className='autosuggest-textarea'>
|
<div className='autosuggest-textarea'>
|
||||||
<label>
|
<label>
|
||||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
inputRef={this.setTextarea}
|
inputRef={this.setTextarea}
|
||||||
className='autosuggest-textarea__textarea'
|
className='autosuggest-textarea__textarea'
|
||||||
|
@ -181,18 +203,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||||
{suggestions.map((suggestion, i) => (
|
{suggestions.map(this.renderSuggestion)}
|
||||||
<div
|
|
||||||
role='button'
|
|
||||||
tabIndex='0'
|
|
||||||
key={suggestion}
|
|
||||||
data-index={suggestion}
|
|
||||||
className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
|
|
||||||
onMouseDown={this.onSuggestionClick}
|
|
||||||
>
|
|
||||||
<AutosuggestAccountContainer id={suggestion} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -48,25 +48,6 @@ const emojify = (str, customEmojis = {}) => {
|
||||||
|
|
||||||
export default emojify;
|
export default emojify;
|
||||||
|
|
||||||
export const toCodePoint = (unicodeSurrogates, sep = '-') => {
|
|
||||||
let r = [], c = 0, p = 0, i = 0;
|
|
||||||
|
|
||||||
while (i < unicodeSurrogates.length) {
|
|
||||||
c = unicodeSurrogates.charCodeAt(i++);
|
|
||||||
|
|
||||||
if (p) {
|
|
||||||
r.push((0x10000 + ((p - 0xD800) << 10) + (c - 0xDC00)).toString(16));
|
|
||||||
p = 0;
|
|
||||||
} else if (0xD800 <= c && c <= 0xDBFF) {
|
|
||||||
p = c;
|
|
||||||
} else {
|
|
||||||
r.push(c.toString(16));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.join(sep);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildCustomEmojis = customEmojis => {
|
export const buildCustomEmojis = customEmojis => {
|
||||||
const emojis = [];
|
const emojis = [];
|
||||||
|
|
||||||
|
@ -76,12 +57,14 @@ export const buildCustomEmojis = customEmojis => {
|
||||||
const name = shortcode.replace(':', '');
|
const name = shortcode.replace(':', '');
|
||||||
|
|
||||||
emojis.push({
|
emojis.push({
|
||||||
|
id: name,
|
||||||
name,
|
name,
|
||||||
short_names: [name],
|
short_names: [name],
|
||||||
text: '',
|
text: '',
|
||||||
emoticons: [],
|
emoticons: [],
|
||||||
keywords: [name],
|
keywords: [name],
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
|
custom: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
|
import { Picker, Emoji } from 'emoji-mart';
|
||||||
import { Overlay } from 'react-overlays';
|
import { Overlay } from 'react-overlays';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { buildCustomEmojis } from '../../../emoji';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
|
||||||
|
@ -26,8 +25,6 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const assetHost = process.env.CDN_HOST || '';
|
const assetHost = process.env.CDN_HOST || '';
|
||||||
|
|
||||||
let EmojiPicker, Emoji; // load asynchronously
|
|
||||||
|
|
||||||
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
|
const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
|
||||||
|
|
||||||
class ModifierPickerMenu extends React.PureComponent {
|
class ModifierPickerMenu extends React.PureComponent {
|
||||||
|
@ -133,7 +130,6 @@ class EmojiPickerMenu extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
custom_emojis: ImmutablePropTypes.list,
|
custom_emojis: ImmutablePropTypes.list,
|
||||||
loading: PropTypes.bool,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
onPick: PropTypes.func.isRequired,
|
onPick: PropTypes.func.isRequired,
|
||||||
style: PropTypes.object,
|
style: PropTypes.object,
|
||||||
|
@ -145,7 +141,6 @@ class EmojiPickerMenu extends React.PureComponent {
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
style: {},
|
style: {},
|
||||||
loading: true,
|
|
||||||
placement: 'bottom',
|
placement: 'bottom',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -220,19 +215,13 @@ class EmojiPickerMenu extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { loading, style, intl } = this.props;
|
const { style, intl } = this.props;
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div style={{ width: 299 }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = intl.formatMessage(messages.emoji);
|
const title = intl.formatMessage(messages.emoji);
|
||||||
const { modifierOpen, modifier } = this.state;
|
const { modifierOpen, modifier } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
|
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
|
||||||
<EmojiPicker
|
<Picker
|
||||||
custom={buildCustomEmojis(this.props.custom_emojis)}
|
|
||||||
perLine={8}
|
perLine={8}
|
||||||
emojiSize={22}
|
emojiSize={22}
|
||||||
sheetSize={32}
|
sheetSize={32}
|
||||||
|
@ -270,7 +259,6 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
active: false,
|
active: false,
|
||||||
loading: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setRef = (c) => {
|
setRef = (c) => {
|
||||||
|
@ -279,18 +267,6 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||||
|
|
||||||
onShowDropdown = () => {
|
onShowDropdown = () => {
|
||||||
this.setState({ active: true });
|
this.setState({ active: true });
|
||||||
|
|
||||||
if (!EmojiPicker) {
|
|
||||||
this.setState({ loading: true });
|
|
||||||
|
|
||||||
EmojiPickerAsync().then(EmojiMart => {
|
|
||||||
EmojiPicker = EmojiMart.Picker;
|
|
||||||
Emoji = EmojiMart.Emoji;
|
|
||||||
this.setState({ loading: false });
|
|
||||||
}).catch(() => {
|
|
||||||
this.setState({ loading: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onHideDropdown = () => {
|
onHideDropdown = () => {
|
||||||
|
@ -298,7 +274,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
onToggle = (e) => {
|
onToggle = (e) => {
|
||||||
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
|
if (!e.key || e.key === 'Enter') {
|
||||||
if (this.state.active) {
|
if (this.state.active) {
|
||||||
this.onHideDropdown();
|
this.onHideDropdown();
|
||||||
} else {
|
} else {
|
||||||
|
@ -324,13 +300,13 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||||
render () {
|
render () {
|
||||||
const { intl, onPickEmoji } = this.props;
|
const { intl, onPickEmoji } = this.props;
|
||||||
const title = intl.formatMessage(messages.emoji);
|
const title = intl.formatMessage(messages.emoji);
|
||||||
const { active, loading } = this.state;
|
const { active } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
<div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
|
||||||
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
<div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
|
||||||
<img
|
<img
|
||||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
className='emojione'
|
||||||
alt='🙂'
|
alt='🙂'
|
||||||
src={`${assetHost}/emoji/1f602.svg`}
|
src={`${assetHost}/emoji/1f602.svg`}
|
||||||
/>
|
/>
|
||||||
|
@ -339,7 +315,6 @@ export default class EmojiPickerDropdown extends React.PureComponent {
|
||||||
<Overlay show={active} placement='bottom' target={this.findTarget}>
|
<Overlay show={active} placement='bottom' target={this.findTarget}>
|
||||||
<EmojiPickerMenu
|
<EmojiPickerMenu
|
||||||
custom_emojis={this.props.custom_emojis}
|
custom_emojis={this.props.custom_emojis}
|
||||||
loading={loading}
|
|
||||||
onClose={this.onHideDropdown}
|
onClose={this.onHideDropdown}
|
||||||
onPick={onPickEmoji}
|
onPick={onPickEmoji}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
export function EmojiPicker () {
|
|
||||||
return import(/* webpackChunkName: "emoji_picker" */'emoji-mart');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Compose () {
|
export function Compose () {
|
||||||
return import(/* webpackChunkName: "features/compose" */'../../compose');
|
return import(/* webpackChunkName: "features/compose" */'../../compose');
|
||||||
}
|
}
|
||||||
|
|
|
@ -110,7 +110,7 @@ export default function accounts(state = initialState, action) {
|
||||||
case BLOCKS_EXPAND_SUCCESS:
|
case BLOCKS_EXPAND_SUCCESS:
|
||||||
case MUTES_FETCH_SUCCESS:
|
case MUTES_FETCH_SUCCESS:
|
||||||
case MUTES_EXPAND_SUCCESS:
|
case MUTES_EXPAND_SUCCESS:
|
||||||
return normalizeAccounts(state, action.accounts);
|
return action.accounts ? normalizeAccounts(state, action.accounts) : state;
|
||||||
case NOTIFICATIONS_REFRESH_SUCCESS:
|
case NOTIFICATIONS_REFRESH_SUCCESS:
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
case SEARCH_FETCH_SUCCESS:
|
case SEARCH_FETCH_SUCCESS:
|
||||||
|
|
|
@ -106,7 +106,7 @@ export default function accountsCounters(state = initialState, action) {
|
||||||
case BLOCKS_EXPAND_SUCCESS:
|
case BLOCKS_EXPAND_SUCCESS:
|
||||||
case MUTES_FETCH_SUCCESS:
|
case MUTES_FETCH_SUCCESS:
|
||||||
case MUTES_EXPAND_SUCCESS:
|
case MUTES_EXPAND_SUCCESS:
|
||||||
return normalizeAccounts(state, action.accounts);
|
return action.accounts ? normalizeAccounts(state, action.accounts) : state;
|
||||||
case NOTIFICATIONS_REFRESH_SUCCESS:
|
case NOTIFICATIONS_REFRESH_SUCCESS:
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
case SEARCH_FETCH_SUCCESS:
|
case SEARCH_FETCH_SUCCESS:
|
||||||
|
|
|
@ -245,7 +245,7 @@ export default function compose(state = initialState, action) {
|
||||||
case COMPOSE_SUGGESTIONS_CLEAR:
|
case COMPOSE_SUGGESTIONS_CLEAR:
|
||||||
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
|
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
|
||||||
case COMPOSE_SUGGESTIONS_READY:
|
case COMPOSE_SUGGESTIONS_READY:
|
||||||
return state.set('suggestions', ImmutableList(action.accounts.map(item => item.id))).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 TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import { STORE_HYDRATE } from '../actions/store';
|
import { STORE_HYDRATE } from '../actions/store';
|
||||||
|
import { emojiIndex } from 'emoji-mart';
|
||||||
|
import { buildCustomEmojis } from '../emoji';
|
||||||
|
|
||||||
const initialState = ImmutableList();
|
const initialState = ImmutableList();
|
||||||
|
|
||||||
export default function statuses(state = initialState, action) {
|
export default function custom_emojis(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STORE_HYDRATE:
|
case STORE_HYDRATE:
|
||||||
|
emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
|
||||||
return action.state.get('custom_emojis');
|
return action.state.get('custom_emojis');
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|
|
@ -1880,15 +1880,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.autosuggest-textarea__suggestions {
|
.autosuggest-textarea__suggestions {
|
||||||
|
box-sizing: border-box;
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
|
box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
|
||||||
background: $ui-secondary-color;
|
background: $ui-secondary-color;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
color: $ui-base-color;
|
color: $ui-base-color;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
padding: 6px;
|
||||||
|
|
||||||
&.autosuggest-textarea__suggestions--visible {
|
&.autosuggest-textarea__suggestions--visible {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -1898,34 +1901,36 @@
|
||||||
.autosuggest-textarea__suggestions__item {
|
.autosuggest-textarea__suggestions__item {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active,
|
||||||
|
&.selected {
|
||||||
background: darken($ui-secondary-color, 10%);
|
background: darken($ui-secondary-color, 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background: $ui-highlight-color;
|
|
||||||
color: $base-border-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.autosuggest-account {
|
.autosuggest-account,
|
||||||
overflow: hidden;
|
.autosuggest-emoji {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
line-height: 18px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autosuggest-account-icon {
|
.autosuggest-account-icon,
|
||||||
float: left;
|
.autosuggest-emoji img {
|
||||||
margin-right: 5px;
|
display: block;
|
||||||
|
margin-right: 8px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.autosuggest-status {
|
.autosuggest-account .display-name__account {
|
||||||
overflow: hidden;
|
color: lighten($ui-base-color, 36%);
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.character-counter__wrapper {
|
.character-counter__wrapper {
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'singleton'
|
|
||||||
|
|
||||||
class Emoji
|
|
||||||
include Singleton
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
data = Oj.load(File.open(Rails.root.join('lib', 'assets', 'emoji.json')))
|
|
||||||
|
|
||||||
@map = {}
|
|
||||||
|
|
||||||
data.each do |_, emoji|
|
|
||||||
keys = [emoji['shortname']] + emoji['aliases']
|
|
||||||
unicode = codepoint_to_unicode(emoji['unicode'])
|
|
||||||
|
|
||||||
keys.each do |key|
|
|
||||||
@map[key] = unicode
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def unicode(shortcode)
|
|
||||||
@map[shortcode]
|
|
||||||
end
|
|
||||||
|
|
||||||
def names
|
|
||||||
@map.keys
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def codepoint_to_unicode(codepoint)
|
|
||||||
if codepoint.include?('-')
|
|
||||||
codepoint.split('-').map(&:hex).pack('U*')
|
|
||||||
else
|
|
||||||
[codepoint.hex].pack('U')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -52,7 +52,6 @@ class Account < ApplicationRecord
|
||||||
include AccountInteractions
|
include AccountInteractions
|
||||||
include Attachmentable
|
include Attachmentable
|
||||||
include Remotable
|
include Remotable
|
||||||
include EmojiHelper
|
|
||||||
|
|
||||||
enum protocol: [:ostatus, :activitypub]
|
enum protocol: [:ostatus, :activitypub]
|
||||||
|
|
||||||
|
@ -269,9 +268,6 @@ class Account < ApplicationRecord
|
||||||
def prepare_contents
|
def prepare_contents
|
||||||
display_name&.strip!
|
display_name&.strip!
|
||||||
note&.strip!
|
note&.strip!
|
||||||
|
|
||||||
self.display_name = emojify(display_name)
|
|
||||||
self.note = emojify(note)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_keys
|
def generate_keys
|
||||||
|
|
|
@ -30,7 +30,6 @@ class Status < ApplicationRecord
|
||||||
include Streamable
|
include Streamable
|
||||||
include Cacheable
|
include Cacheable
|
||||||
include StatusThreadingConcern
|
include StatusThreadingConcern
|
||||||
include EmojiHelper
|
|
||||||
|
|
||||||
enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
|
enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
|
||||||
|
|
||||||
|
@ -267,9 +266,6 @@ class Status < ApplicationRecord
|
||||||
def prepare_contents
|
def prepare_contents
|
||||||
text&.strip!
|
text&.strip!
|
||||||
spoiler_text&.strip!
|
spoiler_text&.strip!
|
||||||
|
|
||||||
self.text = emojify(text)
|
|
||||||
self.spoiler_text = emojify(spoiler_text)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_reblog
|
def set_reblog
|
||||||
|
|
|
@ -28,7 +28,6 @@
|
||||||
%link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
|
%link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
|
||||||
%link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
|
%link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
|
||||||
%link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
|
%link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
|
||||||
%link{ href: asset_pack_path('emoji_picker.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
|
|
||||||
|
|
||||||
= javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
|
= javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
|
||||||
= csrf_meta_tags
|
= csrf_meta_tags
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,20 +0,0 @@
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe EmojiHelper, type: :helper do
|
|
||||||
describe '#emojify' do
|
|
||||||
it 'converts shortcodes to unicode' do
|
|
||||||
text = ':book: Book'
|
|
||||||
expect(emojify(text)).to eq '📖 Book'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'converts composite emoji shortcodes to unicode' do
|
|
||||||
text = ':couple_ww:'
|
|
||||||
expect(emojify(text)).to eq '👩❤👩'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not convert shortcodes that are part of a string into unicode' do
|
|
||||||
text = ':see_no_evil::hear_no_evil::speak_no_evil:'
|
|
||||||
expect(emojify(text)).to eq text
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,15 +0,0 @@
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Emoji do
|
|
||||||
describe '#unicode' do
|
|
||||||
it 'returns a unicode for a shortcode' do
|
|
||||||
expect(Emoji.instance.unicode(':joy:')).to eq '😂'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#names' do
|
|
||||||
it 'returns an array' do
|
|
||||||
expect(Emoji.instance.names).to be_an Array
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
Reference in New Issue