* 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 oncemaster
@@ -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 { 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, | |||
}); | |||
@@ -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 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> | |||
); | |||
@@ -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, | |||
}); | |||
}); | |||
@@ -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} | |||
/> | |||
@@ -1,7 +1,3 @@ | |||
export function EmojiPicker () { | |||
return import(/* webpackChunkName: "emoji_picker" */'emoji-mart'); | |||
} | |||
export function Compose () { | |||
return import(/* webpackChunkName: "features/compose" */'../../compose'); | |||
} | |||
@@ -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: | |||
@@ -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: | |||
@@ -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: | |||
@@ -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; | |||
@@ -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 { | |||
@@ -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 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 | |||
@@ -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 | |||
@@ -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 | |||
@@ -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 |