Browse Source

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 once
fox-changes
Eugen Rochko 2 years ago
parent
commit
1e02ba111a

+ 0
- 24
app/helpers/emoji_helper.rb View File

@@ -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

+ 29
- 6
app/javascript/mastodon/actions/compose.js View File

@@ -1,4 +1,5 @@
import api from '../api';
import { emojiIndex } from 'emoji-mart';

import {
updateTimeline,
@@ -210,19 +211,33 @@ export function clearComposeSuggestions() {

export function fetchComposeSuggestions(token) {
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', {
params: {
q: token,
q: token.slice(1),
resolve: false,
limit: 4,
},
}).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 {
type: COMPOSE_SUGGESTIONS_READY,
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) => {
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({
type: COMPOSE_SUGGESTION_SELECT,
position,
position: startPosition,
token,
completion,
});

+ 37
- 0
app/javascript/mastodon/components/autosuggest_emoji.js View File

@@ -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>
);
}

}

+ 27
- 16
app/javascript/mastodon/components/autosuggest_textarea.js View File

@@ -1,10 +1,12 @@
import React from 'react';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { isRtl } from '../rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import classNames from 'classnames';

const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
@@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, 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];
}

word = word.trim().toLowerCase().slice(1);
word = word.trim().toLowerCase();

if (word.length > 0) {
return [left + 1, word];
@@ -128,7 +130,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
}

onSuggestionClick = (e) => {
const suggestion = e.currentTarget.getAttribute('data-index');
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
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 () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
const { suggestionsHidden, selectedSuggestion } = this.state;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };

if (isRtl(value)) {
@@ -164,6 +185,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>

<Textarea
inputRef={this.setTextarea}
className='autosuggest-textarea__textarea'
@@ -181,18 +203,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
</label>

<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map((suggestion, i) => (
<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>
))}
{suggestions.map(this.renderSuggestion)}
</div>
</div>
);

+ 2
- 19
app/javascript/mastodon/emoji.js View File

@@ -48,25 +48,6 @@ const emojify = (str, customEmojis = {}) => {

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 => {
const emojis = [];

@@ -76,12 +57,14 @@ export const buildCustomEmojis = customEmojis => {
const name = shortcode.replace(':', '');

emojis.push({
id: name,
name,
short_names: [name],
text: '',
emoticons: [],
keywords: [name],
imageUrl: url,
custom: true,
});
});


+ 6
- 31
app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js View File

@@ -1,11 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
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 classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { buildCustomEmojis } from '../../../emoji';

const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -26,8 +25,6 @@ const messages = defineMessages({

const assetHost = process.env.CDN_HOST || '';

let EmojiPicker, Emoji; // load asynchronously

const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;

class ModifierPickerMenu extends React.PureComponent {
@@ -133,7 +130,6 @@ class EmojiPickerMenu extends React.PureComponent {

static propTypes = {
custom_emojis: ImmutablePropTypes.list,
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired,
style: PropTypes.object,
@@ -145,7 +141,6 @@ class EmojiPickerMenu extends React.PureComponent {

static defaultProps = {
style: {},
loading: true,
placement: 'bottom',
};

@@ -220,19 +215,13 @@ class EmojiPickerMenu extends React.PureComponent {
}

render () {
const { loading, style, intl } = this.props;

if (loading) {
return <div style={{ width: 299 }} />;
}

const { style, intl } = this.props;
const title = intl.formatMessage(messages.emoji);
const { modifierOpen, modifier } = this.state;

return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
custom={buildCustomEmojis(this.props.custom_emojis)}
<Picker
perLine={8}
emojiSize={22}
sheetSize={32}
@@ -270,7 +259,6 @@ export default class EmojiPickerDropdown extends React.PureComponent {

state = {
active: false,
loading: false,
};

setRef = (c) => {
@@ -279,18 +267,6 @@ export default class EmojiPickerDropdown extends React.PureComponent {

onShowDropdown = () => {
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 = () => {
@@ -298,7 +274,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
}

onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (!e.key || e.key === 'Enter') {
if (this.state.active) {
this.onHideDropdown();
} else {
@@ -324,13 +300,13 @@ export default class EmojiPickerDropdown extends React.PureComponent {
render () {
const { intl, onPickEmoji } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state;
const { active } = this.state;

return (
<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}>
<img
className={classNames('emojione', { 'pulse-loading': active && loading })}
className='emojione'
alt='🙂'
src={`${assetHost}/emoji/1f602.svg`}
/>
@@ -339,7 +315,6 @@ export default class EmojiPickerDropdown extends React.PureComponent {
<Overlay show={active} placement='bottom' target={this.findTarget}>
<EmojiPickerMenu
custom_emojis={this.props.custom_emojis}
loading={loading}
onClose={this.onHideDropdown}
onPick={onPickEmoji}
/>

+ 0
- 4
app/javascript/mastodon/features/ui/util/async-components.js View File

@@ -1,7 +1,3 @@
export function EmojiPicker () {
return import(/* webpackChunkName: "emoji_picker" */'emoji-mart');
}

export function Compose () {
return import(/* webpackChunkName: "features/compose" */'../../compose');
}

+ 1
- 1
app/javascript/mastodon/reducers/accounts.js View File

@@ -110,7 +110,7 @@ export default function accounts(state = initialState, action) {
case BLOCKS_EXPAND_SUCCESS:
case MUTES_FETCH_SUCCESS:
case MUTES_EXPAND_SUCCESS:
return normalizeAccounts(state, action.accounts);
return action.accounts ? normalizeAccounts(state, action.accounts) : state;
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
case SEARCH_FETCH_SUCCESS:

+ 1
- 1
app/javascript/mastodon/reducers/accounts_counters.js View File

@@ -106,7 +106,7 @@ export default function accountsCounters(state = initialState, action) {
case BLOCKS_EXPAND_SUCCESS:
case MUTES_FETCH_SUCCESS:
case MUTES_EXPAND_SUCCESS:
return normalizeAccounts(state, action.accounts);
return action.accounts ? normalizeAccounts(state, action.accounts) : state;
case NOTIFICATIONS_REFRESH_SUCCESS:
case NOTIFICATIONS_EXPAND_SUCCESS:
case SEARCH_FETCH_SUCCESS:

+ 1
- 1
app/javascript/mastodon/reducers/compose.js View File

@@ -245,7 +245,7 @@ export default function compose(state = initialState, action) {
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
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:
return insertSuggestion(state, action.position, action.token, action.completion);
case TIMELINE_DELETE:

+ 4
- 1
app/javascript/mastodon/reducers/custom_emojis.js View File

@@ -1,11 +1,14 @@
import { List as ImmutableList } from 'immutable';
import { STORE_HYDRATE } from '../actions/store';
import { emojiIndex } from 'emoji-mart';
import { buildCustomEmojis } from '../emoji';

const initialState = ImmutableList();

export default function statuses(state = initialState, action) {
export default function custom_emojis(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
return action.state.get('custom_emojis');
default:
return state;

+ 25
- 20
app/javascript/styles/components.scss View File

@@ -1880,15 +1880,18 @@
}

.autosuggest-textarea__suggestions {
box-sizing: border-box;
display: none;
position: absolute;
top: 100%;
width: 100%;
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;
border-radius: 0 0 4px 4px;
color: $ui-base-color;
font-size: 14px;
padding: 6px;

&.autosuggest-textarea__suggestions--visible {
display: block;
@@ -1898,34 +1901,36 @@
.autosuggest-textarea__suggestions__item {
padding: 10px;
cursor: pointer;
border-radius: 4px;

&:hover {
background: darken($ui-secondary-color, 10%);
}

&:hover,
&:focus,
&:active,
&.selected {
background: $ui-highlight-color;
color: $base-border-color;
background: darken($ui-secondary-color, 10%);
}
}

.autosuggest-account {
overflow: hidden;
.autosuggest-account,
.autosuggest-emoji {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
line-height: 18px;
font-size: 14px;
}

.autosuggest-account-icon {
float: left;
margin-right: 5px;
.autosuggest-account-icon,
.autosuggest-emoji img {
display: block;
margin-right: 8px;
width: 16px;
height: 16px;
}

.autosuggest-status {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;

strong {
font-weight: 500;
}
.autosuggest-account .display-name__account {
color: lighten($ui-base-color, 36%);
}

.character-counter__wrapper {

+ 0
- 40
app/lib/emoji.rb View File

@@ -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

+ 0
- 4
app/models/account.rb View File

@@ -52,7 +52,6 @@ class Account < ApplicationRecord
include AccountInteractions
include Attachmentable
include Remotable
include EmojiHelper

enum protocol: [:ostatus, :activitypub]

@@ -269,9 +268,6 @@ class Account < ApplicationRecord
def prepare_contents
display_name&.strip!
note&.strip!

self.display_name = emojify(display_name)
self.note = emojify(note)
end

def generate_keys

+ 0
- 4
app/models/status.rb View File

@@ -30,7 +30,6 @@ class Status < ApplicationRecord
include Streamable
include Cacheable
include StatusThreadingConcern
include EmojiHelper

enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility

@@ -267,9 +266,6 @@ class Status < ApplicationRecord
def prepare_contents
text&.strip!
spoiler_text&.strip!

self.text = emojify(text)
self.spoiler_text = emojify(spoiler_text)
end

def set_reblog

+ 0
- 1
app/views/layouts/application.html.haml View File

@@ -28,7 +28,6 @@
%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/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'
= csrf_meta_tags

+ 0
- 1
lib/assets/emoji.json
File diff suppressed because it is too large
View File


+ 0
- 20
spec/helpers/emoji_helper_spec.rb View File

@@ -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

+ 0
- 15
spec/lib/emoji_spec.rb View File

@@ -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

Loading…
Cancel
Save