* 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 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) => ( | |||||
<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> | ||||
</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; | |||||
if (loading) { | |||||
return <div style={{ width: 299 }} />; | |||||
} | |||||
const { style, intl } = this.props; | |||||
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 | |||||
custom={buildCustomEmojis(this.props.custom_emojis)} | |||||
<Picker | |||||
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 { | |||||
background: darken($ui-secondary-color, 10%); | |||||
} | |||||
&:hover, | |||||
&:focus, | |||||
&:active, | |||||
&.selected { | &.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 { | .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 | ||||
@@ -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 |