1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-03 00:18:31 +00:00

Add Emoji Rendering. Add basic emoji tab-completion. The emote menu needs a re-think for performance. Strip out more Apollo bugs. Fix tooltips being silly.

This commit is contained in:
SirStendec 2018-04-12 20:30:00 -04:00
parent 1b2ff27530
commit 9c95335743
14 changed files with 504 additions and 28 deletions

View file

@ -1,4 +1,16 @@
<div class="list-header">4.0.0-beta2.13<span>@64fec6b80d1f6a60c263</span> <time datetime="2018-04-12">(2018-04-12)</time></div>
<div class="list-header">4.0.0-beta2.15<span>@61e6d676fdac89cf0592</span> <time datetime="2018-04-12">(2018-04-12)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Added: Emoji Rendering.</li>
<li>Added: Basic emoji tab-completion.</li>
<li>&nbsp;</li>
<li>Emoji aren't in the emote menu yet due to performance concerns. They'll get there. We just have to refactor the menu a bit.</li>
<li>Tab-completion also isn't great for emoji. We need to change how input handling works overall, but it isn't the priority yet.</li>
<li>&nbsp;</li>
<li>Fixed: Sometimes a tooltip hover method is called with no target.</li>
<li>Fixed: More junk logging from Apollo is stripped out.</li>
</ul>
<div class="list-header">4.0.0-beta2.14<span>@d66f702097d2c0295697</span> <time datetime="2018-04-12">(2018-04-12)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Issue sorting settings on Edge and Safari.</li>
<li>Fixed: Issue processing metadata on game pages when broadcasters aren't defined.</li>

5
package-lock.json generated
View file

@ -2782,6 +2782,11 @@
"minimalistic-crypto-utils": "1.0.1"
}
},
"emoji-regex": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.5.1.tgz",
"integrity": "sha512-PAHp6TxrCy7MGMFidro8uikr+zlJJKJ/Q6mm2ExZ7HwkyR9lSVFfE3kt36qcwa24BQL7y0G9axycGjK1A/0uNQ=="
},
"emojis-list": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz",

View file

@ -51,6 +51,7 @@
"dependencies": {
"crypto-js": "^3.1.9-1",
"displacejs": "^1.2.4",
"emoji-regex": "^6.5.1",
"graphql": "^0.13.2",
"graphql-tag": "^2.8.0",
"js-cookie": "^2.2.0",

View file

@ -100,7 +100,7 @@ class FrankerFaceZ extends Module {
FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = {
major: 4, minor: 0, revision: 0, extra: '-beta2.14',
major: 4, minor: 0, revision: 0, extra: '-beta2.15',
build: __webpack_hash__,
toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}`

180
src/modules/chat/emoji.js Normal file
View file

@ -0,0 +1,180 @@
'use strict';
// ============================================================================
// Emoji Handling
// ============================================================================
import Module from 'utilities/module';
import {SERVER} from 'utilities/constants';
import splitter from 'emoji-regex/es2015/index';
export const SIZES = {
apple: [64, 160],
emojione: [64],
facebook: [64, 96],
google: [64, 136],
messenger: [64, 128],
twitter: [64, 72]
}
export function codepoint_to_emoji(cp) {
let code = typeof cp === 'number' ? cp : parseInt(cp, 16);
if ( code < 0x10000 )
return String.fromCharCode(code);
code -= 0x10000;
return String.fromCharCode(
0xD800 + (code >> 10),
0xDC00 + (code & 0x3FF)
);
}
export default class Emoji extends Module {
constructor(...args) {
super(...args);
this.inject('..emotes');
this.inject('settings');
this.settings.add('chat.emoji.style', {
default: 'twitter',
ui: {
path: 'Chat > Appearance >> Emoji',
title: 'Emoji Style',
component: 'setting-select-box',
data: [
{value: 0, title: 'Native'},
{value: 'twitter', title: 'Twitter'},
{value: 'google', title: 'Google'},
//{value: 'apple', title: 'Apple'},
{value: 'emojione', title: 'EmojiOne'},
//{value: 'facebook', title: 'Facebook'},
//{value: 'messenger', title: 'Messenger'}
]
}
});
// For some reason, splitter is a function.
this.splitter = splitter();
this.emoji = {};
this.names = {};
this.chars = new Map;
}
onEnable() {
this.loadEmojiData();
}
async loadEmojiData(tries = 0) {
let data;
try {
data = await fetch(`${SERVER}/script/emoji/v2-.json?_${Date.now()}`).then(r =>
r.ok ? r.json() : null
);
} catch(err) {
tries++;
if ( tries < 10 )
return setTimeout(() => this.loadEmojiData(tries), 500 * tries);
this.log.error('Error loading emoji data.', err);
return false;
}
if ( ! data )
return false;
const cats = data.c,
out = {},
names = {},
chars = new Map;
for(const raw of data.e) {
const emoji = Object.assign(hydrate_emoji(raw.slice(4)), {
category: cats[raw[0]],
sort: raw[1],
names: raw[2],
name: raw[3]
});
if ( ! Array.isArray(emoji.names) )
emoji.names = [emoji.names];
if ( ! emoji.name )
emoji.name = emoji.names[0].replace(/_/g, ' ');
out[emoji.code] = emoji;
chars.set(emoji.raw, [emoji.code, null]);
for(const name of emoji.names)
names[name] = emoji.code;
// Variations
if ( raw[7] ) {
const vars = emoji.variants = {};
for(const r of raw[7]) {
const vari = Object.assign(hydrate_emoji(r), {
key: r[3].toLowerCase()
});
vars[vari.key] = vari;
chars.set(vari.raw, [emoji.code, vari.key]);
}
}
}
this.emoji = out;
this.names = names;
this.chars = chars;
this.log.info(`Loaded data about ${Object.keys(out).length} emoji.`);
this.emit(':populated');
return true;
}
getFullImage(image, style) {
if ( ! style )
style = this.parent.context.get('chat.emoji.style');
if ( style === 0 )
style = 'twitter';
return `${SERVER}/static/emoji/img-${style}-${SIZES[style][0]}/${image}`;
}
getFullImageSet(image, style) {
if ( ! style )
style = this.parent.context.get('chat.emoji.style');
if ( style === 0 )
style = 'twitter';
return SIZES[style].map(w =>
`${SERVER}/static/emoji/img-${style}-${w}/${image} ${w}w`
).join(', ');
}
}
function hydrate_emoji(data) {
return {
code: data[0],
image: `${data[0]}.png`,
raw: data[0].split('-').map(codepoint_to_emoji).join(''),
sheet_x: data[1][0],
sheet_y: data[1][1],
has: {
apple: !!(0b100000 & data[2]),
google: !!(0b010000 & data[2]),
twitter: !!(0b001000 & data[2]),
emojione: !!(0b000100 & data[2]),
facebook: !!(0b000010 & data[2]),
messenger: !!(0b000001 & data[2])
}
};
}

View file

@ -247,6 +247,10 @@ export default class Emotes extends Module {
source = emote_set.source || 'ffz';
id = emote.id;
} else if ( provider === 'emoji' ) {
source = 'emoji';
id = ds.code;
} else
return;
@ -467,6 +471,8 @@ export default class Emotes extends Module {
if ( data.users )
this.loadSetUsers(data.users);
return true;
}

View file

@ -10,7 +10,7 @@ import {timeout, has} from 'utilities/object';
import Badges from './badges';
import Emotes from './emotes';
//import Emoji from './emoji';
import Emoji from './emoji';
import Room from './room';
import User from './user';
@ -32,7 +32,7 @@ export default class Chat extends Module {
this.inject(Badges);
this.inject(Emotes);
//this.inject(Emoji);
this.inject(Emoji);
this._link_info = {};

View file

@ -437,7 +437,7 @@ export const AddonEmotes = {
render(token, createElement) {
const mods = token.modifiers || [], ml = mods.length,
emote = (<img
class={`${EMOTE_CLASS} ffz-tooltip${token.provider === 'ffz' ? ' ffz-emote' : ''}`}
class={`${EMOTE_CLASS} ffz-tooltip${token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`}
src={token.src}
srcSet={token.srcSet}
alt={token.text}
@ -445,6 +445,8 @@ export const AddonEmotes = {
data-provider={token.provider}
data-id={token.id}
data-set={token.set}
data-code={token.code}
data-variant={token.variant}
data-modifiers={ml ? mods.map(x => x.id).join(' ') : null}
data-modifier-info={ml ? JSON.stringify(mods.map(x => [x.set, x.id])) : null}
onClick={this.emotes.handleClick}
@ -461,7 +463,7 @@ export const AddonEmotes = {
onClick={this.emotes.handleClick}
>
{emote}
{mods.map(t => <span key={t.text}>{this.tokenizers.emote.render(t, createElement)}</span>)}
{mods.map(t => <span key={t.text}>{this.tokenizers.emote.render.call(this, t, createElement)}</span>)}
</span>);
},
@ -470,7 +472,9 @@ export const AddonEmotes = {
provider = ds.provider,
modifiers = ds.modifierInfo;
let preview, source, owner, mods, fav_source, emote_id;
let name, preview, source, owner, mods, fav_source, emote_id,
plain_name = false,
hide_source = ds.noSource === 'true';
if ( modifiers && modifiers !== 'null' ) {
mods = JSON.parse(modifiers).map(([set_id, emote_id]) => {
@ -479,7 +483,7 @@ export const AddonEmotes = {
if ( emote )
return (<span>
{this.tokenizers.emote.render(emote.token, createElement)}
{this.tokenizers.emote.render.call(this, emote.token, createElement)}
{` - ${emote.hidden ? '???' : emote.name}`}
</span>);
})
@ -532,21 +536,42 @@ export const AddonEmotes = {
preview = emote.urls[2];
}
} else if ( provider === 'emoji' ) {
const emoji = this.emoji.emoji[ds.code],
style = this.context.get('chat.emoji.style'),
variant = ds.variant ? emoji.variants[ds.variant] : emoji,
vcode = ds.variant ? this.emoji.emoji[ds.variant] : null;
fav_source = 'emoji';
emote_id = ds.code;
preview = (<img
class="preview-image ffz-emoji"
src={this.emoji.getFullImage(variant.image, style)}
srcSet={this.emoji.getFullImageSet(variant.image, style)}
onLoad={tip.update}
/>);
plain_name = true;
name = `:${emoji.names[0]}:${vcode ? `:${vcode.names[0]}:` : ''}`;
source = this.i18n.t('tooltip.emoji', 'Emoji - %{category}', emoji);
} else
return;
const name = ds.name || target.alt,
favorite = fav_source && this.emotes.isFavorite(fav_source, emote_id),
hide_source = ds.noSource === 'true';
if ( ! name )
name = ds.name || target.alt;
const favorite = fav_source && this.emotes.isFavorite(fav_source, emote_id);
return [
preview && this.context.get('tooltip.emote-images') && (<img
preview && this.context.get('tooltip.emote-images') && (typeof preview === 'string' ? (<img
class="preview-image"
src={preview}
onLoad={tip.update}
/>),
/>) : preview),
(hide_source && ! owner) ? name : this.i18n.t('tooltip.emote', 'Emote: %{code}', {code: ds.name || target.alt}),
plain_name || (hide_source && ! owner) ? name : this.i18n.t('tooltip.emote', 'Emote: %{name}', {name}),
! hide_source && source && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05">
{source}
@ -636,6 +661,80 @@ export const AddonEmotes = {
}
// ============================================================================
// Emoji
// ============================================================================
export const Emoji = {
type: 'emoji',
priority: 15,
process(tokens) {
if ( ! tokens || ! tokens.length )
return tokens;
const splitter = this.emoji.splitter,
style = this.context.get('chat.emoji.style'),
out = [];
if ( style === 0 )
return tokens;
for(const token of tokens) {
if ( ! token )
continue;
if ( token.type !== 'text' ) {
out.push(token);
continue;
}
const text = token.text;
splitter.lastIndex = 0;
let idx = 0, match;
while((match = splitter.exec(text))) {
const start = match.index,
key = this.emoji.chars.get(match[0]);
if ( ! key )
continue;
const emoji = this.emoji.emoji[key[0]],
variant = key[1] ? emoji.variants[key[1]] : emoji,
length = split_chars(match[0]).length;
if ( idx !== start )
out.push({type: 'text', text: text.slice(idx, start)});
out.push({
type: 'emote',
provider: 'emoji',
code: key[0],
variant: key[1],
src: this.emoji.getFullImage(variant.image, style),
srcSet: this.emoji.getFullImageSet(variant.image, style),
text: match[0],
length,
modifiers: []
});
idx = start + match[0].length;
}
if ( idx < text.length )
out.push({type: 'text', text: text.slice(idx)});
}
return out;
}
}
// ============================================================================
// Twitch Emotes
// ============================================================================

View file

@ -77,6 +77,7 @@ export default class EmoteMenu extends Module {
this.inject('chat');
this.inject('chat.badges');
this.inject('chat.emotes');
this.inject('chat.emoji');
this.inject('site');
this.inject('site.fine');
@ -201,7 +202,8 @@ export default class EmoteMenu extends Module {
this.on('chat.emotes:update-default-sets', this.maybeUpdate, this);
this.on('chat.emotes:update-user-sets', this.maybeUpdate, this);
this.on('chat.emotes:update-room-sets', this.maybeUpdate, this);
this.on('chat.emotes:change-favorite', this.maybeUpdate, this);
this.on('chat.emotes:change-favorite', this.updateFavorite, this);
this.on('chat.emoji:populated', this.updateEmoji, this);
this.chat.context.on('changed:chat.emote-menu.enabled', () =>
this.EmoteMenu.forceUpdate());
@ -264,6 +266,14 @@ export default class EmoteMenu extends Module {
this.EmoteMenu.forceUpdate();
}
updateFavorite() {
this.maybeUpdate();
}
updateEmoji() {
this.maybeUpdate();
}
defineClasses() {
const t = this,
@ -300,6 +310,8 @@ export default class EmoteMenu extends Module {
data-provider={data.provider}
data-id={data.id}
data-set={data.set_id}
data-code={data.code}
data-variant={data.variant}
data-no-source={this.props.source}
data-name={data.name}
aria-label={data.name}
@ -309,7 +321,7 @@ export default class EmoteMenu extends Module {
>
<figure class="emote-picker__emote-figure">
<img
class="emote-picker__emote-image"
class={`emote-picker__emote-image${data.emoji ? ' ffz-emoji' : ''}`}
src={data.src}
srcSet={data.srcSet}
alt={data.name}
@ -331,7 +343,7 @@ export default class EmoteMenu extends Module {
super(props);
const collapsed = storage.get('emote-menu.collapsed') || [];
this.state = {collapsed: props.data && collapsed.includes(props.data.key)}
this.state = {collapsed: props.data && (collapsed.includes(props.data.key) !== props.data.collapsed)}
this.clickHeading = this.clickHeading.bind(this);
this.onMouseEnter = this.onMouseEnter.bind(this);
@ -344,11 +356,14 @@ export default class EmoteMenu extends Module {
const collapsed = storage.get('emote-menu.collapsed') || [],
key = this.props.data.key,
idx = collapsed.indexOf(key),
val = ! this.state.collapsed;
idx = collapsed.indexOf(key);
let val = ! this.state.collapsed;
this.setState({collapsed: val});
if ( this.props.data.collapsed )
val = ! val;
if ( val && idx === -1 )
collapsed.push(key);
else if ( ! val && idx !== -1 )
@ -607,6 +622,7 @@ export default class EmoteMenu extends Module {
state.filtered_channel_sets = this.filterSets(input, state.channel_sets);
state.filtered_all_sets = this.filterSets(input, state.all_sets);
state.filtered_fav_sets = this.filterSets(input, state.fav_sets);
state.filtered_emoji_sets = this.filterSets(input, state.emoji_sets);
return state;
}
@ -633,7 +649,8 @@ export default class EmoteMenu extends Module {
if ( ! filter || ! filter.length )
return true;
const emote_lower = emote.name.toLowerCase(),
const emote_name = emote.search || emote.name,
emote_lower = emote_name.toLowerCase(),
term_lower = filter.toLowerCase();
if ( ! filter.startsWith(':') )
@ -642,12 +659,74 @@ export default class EmoteMenu extends Module {
if ( emote_lower.startsWith(term_lower.slice(1)) )
return true;
const idx = emote.name.indexOf(filter.charAt(1).toUpperCase());
const idx = emote_name.indexOf(filter.charAt(1).toUpperCase());
if ( idx !== -1 )
return emote_lower.slice(idx+1).startsWith(term_lower.slice(2));
}
buildEmoji(old_state) { // eslint-disable-line class-methods-use-this
return old_state;
/*const state = Object.assign({}, old_state),
sets = state.emoji_sets = [],
emoji_favorites = t.emotes.getFavorites('emoji'),
style = t.chat.context.get('chat.emoji.style') || 'twitter',
favorites = state.favorites = state.favorites || [],
categories = {};
for(const emoji of Object.values(t.emoji.emoji)) {
if ( ! emoji.has[style] )
continue;
let cat = categories[emoji.category];
if ( ! cat ) {
cat = categories[emoji.category] = [];
sets.push({
key: `emoji-${emoji.category}`,
collapsed: true,
image: t.emoji.getFullImage(emoji.image),
title: emoji.category,
source: t.i18n.t('emote-menu.emoji', 'Emoji'),
emotes: cat
});
}
const is_fav = emoji_favorites.includes(emoji.code),
em = {
provider: 'emoji',
emoji: true,
code: emoji.code,
name: emoji.raw,
search: emoji.names[0],
height: 18,
width: 18,
x: emoji.sheet_x,
y: emoji.sheet_y,
favorite: is_fav,
src: t.emoji.getFullImage(emoji.image),
srcSet: t.emoji.getFullImageSet(emoji.image)
};
cat.push(em);
if ( is_fav )
favorites.push(em);
}
state.has_emoji_tab = sets.length > 0;
return state;*/
}
buildState(props, old_state) {
const state = Object.assign({}, old_state),
@ -961,7 +1040,7 @@ export default class EmoteMenu extends Module {
// We use this sorter because we don't want things grouped by sets.
favorites.sort(sorter);
return state;
return this.buildEmoji(state);
}
@ -1086,7 +1165,7 @@ export default class EmoteMenu extends Module {
padding = t.chat.context.get('chat.emote-menu.reduced-padding');
let tab = this.state.tab || t.chat.context.get('chat.emote-menu.default-tab'), sets;
if ( tab === 'channel' && ! this.state.has_channel_tab )
if ( (tab === 'channel' && ! this.state.has_channel_tab) || (tab === 'emoji' && ! this.state.has_emoji_tab) )
tab = 'all';
switch(tab) {
@ -1096,6 +1175,9 @@ export default class EmoteMenu extends Module {
case 'channel':
sets = this.state.filtered_channel_sets;
break;
case 'emoji':
sets = this.state.filtered_emoji_sets;
break;
case 'all':
default:
sets = this.state.filtered_all_sets;
@ -1168,6 +1250,14 @@ export default class EmoteMenu extends Module {
>
{t.i18n.t('emote-menu.my-emotes', 'My Emotes')}
</div>
{this.state.has_emoji_tab && <div
class={`emote-picker__tab tw-pd-x-1${tab === 'emoji' ? ' emote-picker__tab--active' : ''}`}
id="emote-picker__emoji"
data-tab="emoji"
onClick={this.clickTab}
>
{t.i18n.t('emote-menu.emoji', 'Emoji')}
</div>}
<div class="tw-flex-grow-1" />
{!loading && (<div
class="ffz-tooltip emote-picker__tab tw-pd-x-1 tw-mg-r-0"

View file

@ -41,6 +41,7 @@ export default class ChatLine extends Module {
}
onEnable() {
this.chat.context.on('changed:chat.emoji.style', this.updateLines, this);
this.chat.context.on('changed:chat.bits.stack', this.updateLines, this);
this.chat.context.on('changed:chat.badges.style', this.updateLines, this);
this.chat.context.on('changed:chat.badges.hidden', this.updateLines, this);

View file

@ -13,6 +13,7 @@ export default class TabCompletion extends Module {
this.inject('chat');
this.inject('chat.emotes');
this.inject('chat.emoji');
this.inject('i18n');
this.inject('settings');
@ -31,6 +32,15 @@ export default class TabCompletion extends Module {
}
});
this.settings.add('chat.tab-complete.emoji', {
default: true,
ui: {
path: 'Chat > Input >> Tab Completion',
title: 'Allow tab-completion of emoji.',
component: 'setting-check-box'
}
});
// Components
@ -103,12 +113,59 @@ export default class TabCompletion extends Module {
}
inst.getMatchedEmotes = function(input) {
const results = old_get_matched.call(this, input);
if ( ! t.chat.context.get('chat.tab-complete.ffz-emotes') )
let results = old_get_matched.call(this, input);
if ( t.chat.context.get('chat.tab-complete.ffz-emotes') )
results = results.concat(t.getEmoteSuggestions(input, this));
if ( ! t.chat.context.get('chat.tab-complete.emoji') )
return results;
return results.concat(t.getEmoteSuggestions(input, this));
return results.concat(t.getEmojiSuggestions(input, this));
}
const React = this.web_munch.getModule('react'),
createElement = React && React.createElement;
inst.renderFFZEmojiSuggestion = function(data) {
return [
<div class="tw-pd-r-05">
<img
class="emote-autocomplete-provider__image ffz-emoji"
src={data.src}
srcSet={data.srcset}
/>
</div>,
<div>
{data.token}
</div>
]
}
}
getEmojiSuggestions(input, inst) {
const search = input.slice(1).toLowerCase(),
style = this.chat.context.get('chat.emoji.style'),
results = [];
for(const name in this.emoji.names)
if ( name.startsWith(search) ) {
const emoji = this.emoji.emoji[this.emoji.names[name]];
if ( emoji && (style === 0 || emoji.has[style]) )
results.push({
current: input,
replacement: emoji.raw,
element: inst.renderFFZEmojiSuggestion({
token: `:${name}:`,
id: `emoji-${emoji.code}`,
src: this.emoji.getFullImage(emoji.image, style),
srcSet: this.emoji.getFullImageSet(emoji.image, style)
})
});
}
return results;
}

View file

@ -40,6 +40,22 @@
}
}
.ffz-emoji {
width: calc(var(--ffz-chat-font-size) * 1.5);
height: calc(var(--ffz-chat-font-size) * 1.5);
&.preview-image {
width: 7.2rem;
height: 7.2rem;
}
&.emote-autocomplete-provider__image {
width: 1.8rem;
height: 1.8rem;
margin: .5rem;
}
}
.ffz--emote-picker {
section:not(.filtered) heading {
@ -90,6 +106,10 @@
pointer-events: none;
}
.emote-picker__emote-image {
max-height: 3.2rem
}
section:last-of-type {
& > div:last-child,
& > heading:last-child {

View file

@ -13,6 +13,12 @@ const BAD_ERRORS = [
'timeout',
'unable to load',
'error internal',
'context deadline exceeded',
'500',
'501',
'502',
'503',
'504',
'Internal Server Error',
'http://',
'https://'
@ -41,7 +47,6 @@ export default class Apollo extends Module {
this.inject('..web_munch');
this.inject('..fine');
//this.inject('core');
}
onEnable() {

View file

@ -73,7 +73,7 @@ export class Tooltip {
} else if ( this.live ) {
this._onMouseOver = e => {
const target = e.target;
if ( target.classList.contains(this.cls) )
if ( target && target.classList.contains(this.cls) )
this._enter(target);
};