diff --git a/changelog.html b/changelog.html
index 7f0175c3..6322e071 100644
--- a/changelog.html
+++ b/changelog.html
@@ -1,3 +1,18 @@
+
+
+
And the biggest features still under development:
- Emoji Rendering
- - Emotes Menu
- Chat Filtering (Highlighted Words, etc.)
- Room Status Indicators
- Custom Mod Cards
@@ -49,7 +47,6 @@
- Portrait Mode
- Importing and exporting settings
- User Aliases
- - Rich Content in Chat (aka Clip Embeds)
diff --git a/src/sites/twitch-twilight/modules/chat/emote_menu.jsx b/src/sites/twitch-twilight/modules/chat/emote_menu.jsx
new file mode 100644
index 00000000..d4ee703f
--- /dev/null
+++ b/src/sites/twitch-twilight/modules/chat/emote_menu.jsx
@@ -0,0 +1,997 @@
+'use strict';
+
+// ============================================================================
+// Chat Emote Menu
+// ============================================================================
+
+import {has, get, once, set_equals} from 'utilities/object';
+import {KNOWN_CODES, TWITCH_EMOTE_BASE} from 'utilities/constants';
+
+import Twilight from 'site';
+import Module from 'utilities/module';
+
+import SUB_STATUS from './sub_status.gql';
+
+function maybe_date(val) {
+ if ( ! val )
+ return val;
+
+ try {
+ return new Date(val);
+ } catch(err) {
+ return null;
+ }
+}
+
+
+function sort_sets(a, b) {
+ const a_sk = a.sort_key,
+ b_sk = b.sort_key;
+
+ if ( a_sk < b_sk ) return -1;
+ if ( b_sk < a_sk ) return 1;
+
+ const a_n = a.title.toLowerCase(),
+ b_n = b.title.toLowerCase();
+
+ if ( a_n < b_n ) return -1;
+ if ( b_n < a_n ) return 1;
+ return 0;
+}
+
+
+export default class EmoteMenu extends Module {
+ constructor(...args) {
+ super(...args);
+
+ this.inject('settings');
+ this.inject('i18n');
+ this.inject('chat');
+ this.inject('chat.badges');
+ this.inject('chat.emotes');
+
+ this.inject('site');
+ this.inject('site.fine');
+ this.inject('site.apollo');
+ this.inject('site.web_munch');
+ this.inject('site.css_tweaks');
+
+ this.SUB_STATUS = SUB_STATUS;
+
+ this.settings.add('chat.emote-menu.enabled', {
+ default: true,
+ ui: {
+ path: 'Chat > Emote Menu >> General',
+ title: 'Use the FrankerFaceZ Emote Menu.',
+ description: 'The FFZ emote menu replaces the built-in Twitch emote menu and provides enhanced functionality.',
+ component: 'setting-check-box'
+ },
+ changed: () => this.EmoteMenu.forceUpdate()
+ });
+
+ this.settings.add('chat.emote-menu.icon', {
+ requires: ['chat.emote-menu.enabled'],
+ default: false,
+ process(ctx, val) {
+ return ctx.get('chat.emote-menu.enabled') ? val : false
+ },
+
+ ui: {
+ path: 'Chat > Emote Menu >> General',
+ title: 'Replace the emote menu icon with the FFZ icon for that classic feel.',
+ component: 'setting-check-box'
+ }
+ })
+
+ this.EmoteMenu = this.fine.define(
+ 'chat-emote-menu',
+ n => n.subscriptionProductHasEmotes,
+ Twilight.CHAT_ROUTES
+ )
+ }
+
+ onEnable() {
+ this.on('i18n:update', () => this.EmoteMenu.forceUpdate());
+ 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.chat.context.on('changed:chat.emote-menu.icon', val =>
+ this.css_tweaks.toggle('emote-menu', val));
+
+ this.css_tweaks.toggle('emote-menu', this.chat.context.get('chat.emote-menu.icon'));
+
+ const t = this,
+ React = this.web_munch.getModule('react'),
+ createElement = React && React.createElement;
+
+ if ( ! createElement )
+ return t.log.warn('Unable to get React.');
+
+ this.defineClasses();
+
+
+ this.EmoteMenu.ready(cls => {
+ const old_render = cls.prototype.render;
+
+ cls.prototype.render = function() {
+ if ( ! this.props || ! has(this.props, 'channelOwnerID') || ! t.chat.context.get('chat.emote-menu.enabled') )
+ return old_render.call(this);
+
+ return ()
+ }
+
+ this.EmoteMenu.forceUpdate();
+ })
+ }
+
+ maybeUpdate() {
+ if ( this.chat.context.get('chat.emote-menu.enabled') )
+ this.EmoteMenu.forceUpdate();
+ }
+
+
+ defineClasses() {
+ const t = this,
+ storage = this.settings.provider,
+ React = this.web_munch.getModule('react'),
+ createElement = React && React.createElement;
+
+ this.MenuEmote = class FFZMenuEmote extends React.Component {
+ constructor(props) {
+ super(props);
+ this.handleClick = props.onClickEmote.bind(this, props.data.name)
+ }
+
+ componentWillUpdate() {
+ this.handleClick = this.props.onClickEmote.bind(this, this.props.data.name);
+ }
+
+ render() {
+ const data = this.props.data,
+ lock = this.props.lock,
+ locked = this.props.locked,
+
+ sellout = lock ?
+ this.props.all_locked ?
+ t.i18n.t('emote-menu.emote-sub', 'Subscribe for %{price} to unlock this emote.', lock) :
+ t.i18n.t('emote-menu.emote-up', 'Upgrade your sub to %{price} to unlock this emote.', lock)
+ : null;
+
+ return ();
+ }
+ }
+
+ this.MenuSection = class FFZMenuSection extends React.Component {
+ constructor(props) {
+ super(props);
+
+ const collapsed = storage.get('emote-menu.collapsed') || [];
+ this.state = {collapsed: props.data && collapsed.includes(props.data.key)}
+
+ this.clickHeading = this.clickHeading.bind(this);
+ this.onMouseEnter = this.onMouseEnter.bind(this);
+ this.onMouseLeave = this.onMouseLeave.bind(this);
+ }
+
+ clickHeading() {
+ if ( this.props.filter )
+ return;
+
+ const collapsed = storage.get('emote-menu.collapsed') || [],
+ key = this.props.data.key,
+ idx = collapsed.indexOf(key),
+ val = ! this.state.collapsed;
+
+ this.setState({collapsed: val});
+
+ if ( val && idx === -1 )
+ collapsed.push(key);
+ else if ( ! val && idx !== -1 )
+ collapsed.splice(idx, 1);
+ else
+ return;
+
+ storage.set('emote-menu.collapsed', collapsed);
+ }
+
+ onMouseEnter(event) {
+ const set_id = parseInt(event.currentTarget.dataset.setId,10);
+ this.setState({unlocked: set_id});
+ }
+
+ onMouseLeave() {
+ this.setState({unlocked: null});
+ }
+
+ render() {
+ const data = this.props.data,
+ collapsed = ! this.props.filtered && this.state.collapsed;
+
+ if ( ! data )
+ return null;
+
+ let image;
+ if ( data.image )
+ image = (
);
+ else
+ image = ();
+
+ let calendar;
+
+ const renews = data.renews && data.renews - new Date,
+ ends = data.ends && data.ends - new Date;
+
+ if ( renews > 0 ) {
+ const time = t.i18n.toHumanTime(renews / 1000);
+ calendar = {
+ icon: 'calendar',
+ message: t.i18n.t('emote-menu.sub-renews', 'This sub renews in %{time}.', {time})
+ }
+
+ } else if ( ends ) {
+ const time = t.i18n.toHumanTime(ends / 1000);
+ if ( data.prime )
+ calendar = {
+ icon: 'crown',
+ message: t.i18n.t('emote-menu.sub-prime', 'This is your free sub with Twitch Prime.\nIt ends in %{time}.', {time})
+ }
+ else
+ calendar = {
+ icon: 'calendar-empty',
+ message: t.i18n.t('emote-menu.sub-ends', 'This sub ends in %{time}.', {time})
+ }
+ }
+
+ return (
+
+ {image}
+
+ {data.title || t.i18n.t('emote-menu.unknown', 'Unknown Source')}
+ {calendar && ()}
+
+
+ {data.source || 'FrankerFaceZ'}
+
+
+ {collapsed || this.renderBody()}
+ )
+ }
+
+ renderBody() {
+ const data = this.props.data,
+ filtered = this.props.filtered,
+ lock = data.locks && data.locks[this.state.unlocked],
+ emotes = data.filtered_emotes && data.filtered_emotes.map(emote => (! filtered || ! emote.locked) && ());
+
+ return (
+ {emotes}
+ {!filtered && this.renderSellout()}
+
)
+ }
+
+ renderSellout() {
+ const data = this.props.data;
+
+ if ( ! data.all_locked || ! data.locks )
+ return null;
+
+ const lock = data.locks[this.state.unlocked];
+
+ return (
+ {lock ?
+ t.i18n.t('emote-menu.sub-unlock', 'Subscribe for %{price} to unlock %{count} emote%{count|en_plural}', {price: lock.price, count: lock.emotes.size}) :
+ t.i18n.t('emote-menu.sub-basic', 'Subscribe to unlock some emotes')}
+
+
)
+ }
+ }
+
+ this.MenuComponent = class FFZEmoteMenuComponent extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {page: null}
+ this.componentWillReceiveProps(props);
+
+ this.clickTab = this.clickTab.bind(this);
+ this.clickRefresh = this.clickRefresh.bind(this);
+ this.handleFilterChange = this.handleFilterChange.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+
+ window.ffz_menu = this;
+ }
+
+ clickTab(event) {
+ this.setState({
+ page: event.target.dataset.page
+ });
+ }
+
+ clickRefresh(event) {
+ const target = event.currentTarget,
+ tt = target && target._ffz_tooltip$0;
+
+ if ( tt && tt.hide )
+ tt.hide();
+
+ this.setState({
+ loading: true
+ }, async () => {
+ const props = this.props,
+ promises = [],
+ emote_data = props.emote_data,
+ channel_data = props.channel_data;
+
+ if ( emote_data )
+ promises.push(emote_data.refetch())
+
+ if ( channel_data )
+ promises.push(channel_data.refetch());
+
+ await Promise.all(promises);
+
+ const es = props.emote_data && props.emote_data.emoteSets,
+ sets = es && es.length ? new Set(es.map(x => parseInt(x.id, 10))) : new Set;
+
+ const data = await t.getData(sets, true);
+ this.setState(this.filterState(this.state.filter, this.buildState(
+ this.props,
+ Object.assign({}, this.state, {set_sets: sets, set_data: data, loading: false})
+ )));
+ });
+ }
+
+ handleFilterChange(event) {
+ this.setState(this.filterState(event.target.value, this.state));
+ }
+
+ handleKeyDown(event) {
+ if ( event.keyCode === 27 )
+ this.props.toggleVisibility();
+ }
+
+ loadData(force = false, props, state) {
+ state = state || this.state;
+ if ( ! state )
+ return false;
+
+ props = props || this.props;
+
+ const emote_sets = props.emote_data && props.emote_data.emoteSets,
+ sets = Array.isArray(emote_sets) ? new Set(emote_sets.map(x => parseInt(x.id, 10))) : new Set;
+
+ force = force || (state.set_data && ! set_equals(state.set_sets, sets));
+
+ if ( state.set_data && ! force )
+ return false;
+
+ this.setState({loading: true}, () => {
+ t.getData(sets, force).then(d => {
+ this.setState(this.filterState(this.state.filter, this.buildState(
+ this.props,
+ Object.assign({}, this.state, {set_sets: sets, set_data: d, loading: false})
+ )));
+ });
+ });
+
+ return true;
+ }
+
+ loadSets(props) { // eslint-disable-line class-methods-use-this
+ const emote_sets = props.emote_data && props.emote_data.emoteSets;
+ if ( ! emote_sets || ! emote_sets.length )
+ return;
+
+ for(const emote_set of emote_sets) {
+ const set_id = parseInt(emote_set.id, 10);
+ t.emotes.getTwitchSetChannel(set_id)
+ }
+ }
+
+ filterState(input, old_state) {
+ const state = Object.assign({}, old_state);
+
+ state.filter = input;
+ state.filtered = input && input.length > 0 && input !== ':' || false;
+
+ state.filtered_channel_sets = this.filterSets(input, state.channel_sets);
+ state.filtered_all_sets = this.filterSets(input, state.all_sets);
+
+ return state;
+ }
+
+ filterSets(input, sets) {
+ const out = [];
+ if ( ! sets || ! sets.length )
+ return out;
+
+ const filtering = input && input.length > 0 && input !== ':';
+
+ for(const emote_set of sets) {
+ const filtered = emote_set.filtered_emotes = emote_set.emotes.filter(emote =>
+ ! filtering || (! emote.locked && this.doesEmoteMatch(input, emote)));
+
+ if ( filtered.length )
+ out.push(emote_set);
+ }
+
+ return out;
+ }
+
+ doesEmoteMatch(filter, emote) { //eslint-disable-line class-methods-use-this
+ if ( ! filter || ! filter.length )
+ return true;
+
+ const emote_lower = emote.name.toLowerCase(),
+ term_lower = filter.toLowerCase();
+
+ if ( ! filter.startsWith(':') )
+ return emote_lower.includes(term_lower);
+
+ if ( emote_lower.startsWith(term_lower.slice(1)) )
+ return true;
+
+ const idx = emote.name.indexOf(filter.charAt(1).toUpperCase());
+ if ( idx !== -1 )
+ return emote_lower.slice(idx+1).startsWith(term_lower.slice(2));
+ }
+
+
+ buildState(props, old_state) {
+ const state = Object.assign({}, old_state),
+
+ data = state.set_data || {},
+ channel = state.channel_sets = [],
+ all = state.all_sets = [];
+
+ // If we're still loading, don't set any data.
+ if ( props.loading || props.error || state.loading )
+ return state;
+
+ // If we start loading because the sets we have
+ // don't match, don't set any data either.
+ if ( state.set_data && this.loadData(false, props, state) )
+ return state;
+
+ // Start with the All tab. Some data calculated for
+ // all is re-used for the Channel tab.
+
+ const emote_sets = props.emote_data && props.emote_data.emoteSets,
+ inventory = t.emotes.twitch_inventory_sets || new Set,
+ grouped_sets = {},
+ set_ids = new Set;
+
+ if ( Array.isArray(emote_sets) )
+ for(const emote_set of emote_sets) {
+ if ( ! emote_set || ! Array.isArray(emote_set.emotes) )
+ continue;
+
+ const set_id = parseInt(emote_set.id, 10),
+ set_data = data[set_id] || {},
+ more_data = t.emotes.getTwitchSetChannel(set_id),
+ image = set_data.image;
+
+ set_ids.add(set_id);
+
+ let chan = set_data && set_data.user;
+ if ( ! chan && more_data && more_data.c_id )
+ chan = {
+ id: more_data.c_id,
+ login: more_data.c_name,
+ display_name: more_data.c_name,
+ bad: true
+ };
+
+ let key = `twitch-set-${set_id}`,
+ sort_key = 0,
+ icon = 'twitch',
+ title = chan && chan.display_name;
+
+ if ( title )
+ key = `twitch-${chan.id}`;
+
+ else {
+ if ( inventory.has(set_id) ) {
+ title = t.i18n.t('emote-menu.inventory', 'Inventory');
+ key = 'twitch-inventory';
+ icon = 'inventory';
+ sort_key = 50;
+
+ } else if ( more_data ) {
+ title = more_data.c_name;
+
+ if ( title === '--global--' ) {
+ title = t.i18n.t('emote-menu.global', 'Global Emotes');
+ sort_key = 100;
+
+ } else if ( title === '--twitch-turbo--' || title === 'turbo' || title === '--turbo-faces--' ) {
+ title = t.i18n.t('emote-menu.turbo', 'Turbo');
+ sort_key = 75;
+
+ } else if ( title === '--prime--' || title === '--prime-faces--' ) {
+ title = t.i18n.t('emote-menu.prime', 'Prime');
+ icon = 'crown';
+ sort_key = 75;
+ }
+ } else
+ title = t.i18n.t('emote-menu.unknown-set', 'Set #%{set_id}', {set_id})
+ }
+
+ let section, emotes;
+
+ if ( grouped_sets[key] ) {
+ section = grouped_sets[key];
+ emotes = section.emotes;
+
+ if ( chan && ! chan.bad && section.bad ) {
+ section.title = title;
+ section.image = image;
+ section.icon = icon;
+ section.sort_key = sort_key;
+ section.bad = false;
+ }
+
+ } else {
+ emotes = [];
+ section = grouped_sets[key] = {
+ sort_key,
+ bad: chan ? chan.bad : true,
+ key,
+ image,
+ icon,
+ title,
+ source: t.i18n.t('emote-menu.twitch', 'Twitch'),
+ emotes,
+ renews: set_data.renews,
+ ends: set_data.ends,
+ prime: set_data.prime
+ }
+ }
+
+ for(const emote of emote_set.emotes) {
+ const id = parseInt(emote.id, 10),
+ base = `${TWITCH_EMOTE_BASE}${id}`,
+ name = KNOWN_CODES[emote.token] || emote.token;
+
+ emotes.push({
+ provider: 'twitch',
+ id,
+ set_id,
+ name,
+ src: `${base}/1.0`,
+ srcSet: `${base}/1.0 1x, ${base}/2.0 2x`
+ });
+ }
+
+ if ( emotes.length && ! all.includes(section) )
+ all.push(section);
+ }
+
+
+ // Now we handle the current Channel's emotes.
+
+ const user = props.channel_data && props.channel_data.user,
+ products = user && user.subscriptionProducts;
+
+ if ( Array.isArray(products) ) {
+ const badge = t.badges.getTwitchBadge('subscriber', '0', user.id, user.login),
+ emotes = [],
+ locks = {},
+ section = {
+ sort_key: -10,
+ key: `twitch-${user.id}`,
+ image: badge && badge.image1x,
+ icon: 'twitch',
+ title: t.i18n.t('emote-menu.sub-set', 'Subscriber Emotes'),
+ source: t.i18n.t('emote-menu.twitch', 'Twitch'),
+ emotes,
+ locks,
+ all_locked: true
+ };
+
+ for(const product of products) {
+ if ( ! product || ! Array.isArray(product.emotes) )
+ continue;
+
+ const set_id = parseInt(product.emoteSetID, 10),
+ set_data = data[set_id],
+ locked = ! set_ids.has(set_id);
+
+ let lock_set;
+
+ if ( set_data ) {
+ section.renews = set_data.renews;
+ section.ends = set_data.ends;
+ section.prime = set_data.prime;
+ }
+
+ // If the channel is locked, store data about that in the
+ // section so we can show appropriate UI to let people
+ // subscribe. Also include all the already known emotes
+ // in the list of emotes this product unlocks.
+ if ( locked )
+ locks[set_id] = {
+ set_id,
+ id: product.id,
+ price: product.price,
+ url: product.url,
+ emotes: lock_set = new Set(emotes.map(e => e.id))
+ }
+ else
+ section.all_locked = false;
+
+ for(const emote of product.emotes) {
+ const id = parseInt(emote.id, 10),
+ base = `${TWITCH_EMOTE_BASE}${id}`,
+ name = KNOWN_CODES[emote.token] || emote.token;
+
+ emotes.push({
+ provider: 'twitch',
+ id,
+ set_id,
+ name,
+ locked,
+ src: `${base}/1.0`,
+ srcSet: `${base}/1.0 1x, ${base}/2.0 2x`
+ });
+
+ if ( lock_set )
+ lock_set.add(id);
+ }
+ }
+
+ if ( emotes.length )
+ channel.push(section);
+ }
+
+
+ // Finally, emotes added by FrankerFaceZ.
+ const me = t.site.getUser(),
+ ffz_room = t.emotes.getRoomSetsWithSources(me.id, me.login, props.channel_id, null),
+ ffz_global = t.emotes.getGlobalSetsWithSources(me.id, me.login);
+
+ for(const [emote_set, provider] of ffz_room) {
+ const section = this.processFFZSet(emote_set, provider);
+ if ( section )
+ channel.push(section);
+ }
+
+ for(const [emote_set, provider] of ffz_global) {
+ const section = this.processFFZSet(emote_set, provider);
+ if ( section )
+ all.push(section);
+ }
+
+
+ // Sort Sets
+ channel.sort(sort_sets);
+ all.sort(sort_sets);
+
+ state.has_channel_page = channel.length > 0;
+
+ return state;
+ }
+
+
+ processFFZSet(emote_set, provider) { // eslint-disable-line class-methods-use-this
+ if ( ! emote_set || ! emote_set.emotes )
+ return null;
+
+ const pdata = t.emotes.providers.get(provider),
+ source = pdata && pdata.name ?
+ (pdata.i18n_key ?
+ t.i18n.t(pdata.i18n_key, pdata.name, pdata) :
+ pdata.name) :
+ emote_set.source || 'FrankerFaceZ',
+
+ title = provider === 'main' ?
+ t.i18n.t('emote-menu.main-set', 'Channel Emotes') :
+ (emote_set.title || t.i18n.t('emote-menu.unknown', `Set #${emote_set.id}`));
+
+ let sort_key = pdata && pdata.sort_key || emote_set.sort;
+ if ( sort_key == null )
+ sort_key = emote_set.title.toLowerCase().includes('global') ? 100 : 0;
+
+ const emotes = [],
+ section = {
+ sort_key,
+ key: `ffz-${emote_set.id}`,
+ image: emote_set.icon,
+ icon: 'zreknarf',
+ title,
+ source,
+ emotes,
+ };
+
+ for(const emote of Object.values(emote_set.emotes))
+ if ( ! emote.hidden ) {
+ const em = {
+ provider: 'ffz',
+ id: emote.id,
+ set_id: emote_set.id,
+ src: emote.urls[1],
+ srcSet: emote.srcSet,
+ name: emote.name
+ };
+
+ emotes.push(em);
+ }
+
+ if ( emotes.length )
+ return section;
+ }
+
+
+ componentWillReceiveProps(props) {
+ if ( props.visible )
+ this.loadData();
+
+ this.loadSets(props);
+
+ const state = this.buildState(props, this.state);
+ this.setState(this.filterState(state.filter, state));
+ }
+
+ renderError() {
+ return (
+
+
+

+
+ {t.i18n.t('emote-menu.error', 'There was an error rendering this menu.')}
+
+
+
)
+ }
+
+ renderEmpty() { // eslint-disable-line class-methods-use-this
+ return (
+
+

+
+ {this.state.filtered ?
+ t.i18n.t('emote-menu.empty-search', 'There are no matching emotes.') :
+ t.i18n.t('emote-menu.empty', "There's nothing here.")}
+
)
+ }
+
+ renderLoading() { // eslint-disable-line class-methods-use-this
+ return (
+
+ {t.i18n.t('emote-menu.loading', 'Loading...')}
+ )
+ }
+
+ render() {
+ if ( ! this.props.visible )
+ return null;
+
+ const loading = this.state.loading || this.props.loading;
+
+ let page = this.state.page, sets;
+ if ( ! page )
+ page = this.state.has_channel_page ? 'channel' : 'all';
+
+ switch(page) {
+ case 'channel':
+ sets = this.state.filtered_channel_sets;
+ break;
+ case 'all':
+ default:
+ sets = this.state.filtered_all_sets;
+ break;
+ }
+
+ return (
+
+
+
+
+ {loading && this.renderLoading()}
+ {!loading && sets && sets.map(data => ())}
+ {! loading && (! sets || ! sets.length) && this.renderEmpty()}
+
+
+
+
+
+
+ {null && (
+
+
)}
+ {this.state.has_channel_page &&
+ {t.i18n.t('emote-menu.channel', 'Channel')}
+
}
+
+ {t.i18n.t('emote-menu.all', 'All')}
+
+
+ {!loading && (
+
+
)}
+
+
+
+
);
+ }
+ }
+ }
+
+
+ async getData(sets, force) {
+ if ( this._data ) {
+ if ( ! force && set_equals(sets, this._data_sets) )
+ return this._data;
+ else {
+ this._data = null;
+ this._data_sets = null;
+ }
+ }
+
+ let data;
+ try {
+ data = await this.apollo.client.query({
+ query: SUB_STATUS,
+ variables: {
+ first: 100,
+ criteria: {
+ filter: 'ALL'
+ }
+ },
+ fetchPolicy: force ? 'network-only' : 'cache-first'
+ });
+
+ } catch(err) {
+ this.log.warn('Error fetching additional emote menu data.', err);
+ return this._data = null;
+ }
+
+ const out = {},
+ nodes = get('data.currentUser.subscriptionBenefits.edges.@each.node', data);
+
+ if ( nodes && nodes.length )
+ for(const node of nodes) {
+ const product = node.product,
+ set_id = product && product.emoteSetID;
+
+ if ( ! set_id )
+ continue;
+
+ const owner = product.owner || {},
+ badges = owner.broadcastBadges;
+
+ let image;
+ if ( badges )
+ for(const badge of badges)
+ if ( badge.setID === 'subscriber' && badge.version === '0' ) {
+ image = badge.imageURL;
+ break;
+ }
+
+ out[set_id] = {
+ ends: maybe_date(node.endsAt),
+ renews: maybe_date(node.renewsAt),
+ prime: node.purchasedWithPrime,
+
+ set_id: parseInt(set_id, 10),
+ type: product.type,
+ image,
+ user: {
+ id: owner.id,
+ login: owner.login,
+ display_name: owner.displayName
+ }
+ }
+ }
+
+ this._data_sets = sets;
+ return this._data = out;
+ }
+}
+
+
+EmoteMenu.getData = once(EmoteMenu.getData);
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js
index de32cccc..c46c4b8b 100644
--- a/src/sites/twitch-twilight/modules/chat/index.js
+++ b/src/sites/twitch-twilight/modules/chat/index.js
@@ -15,7 +15,7 @@ import Twilight from 'site';
import Scroller from './scroller';
import ChatLine from './line';
import SettingsMenu from './settings_menu';
-//import EmoteMenu from './emote_menu';
+import EmoteMenu from './emote_menu';
const CHAT_TYPES = (e => {
@@ -111,7 +111,7 @@ export default class ChatHook extends Module {
this.inject(Scroller);
this.inject(ChatLine);
this.inject(SettingsMenu);
- //this.inject(EmoteMenu);
+ this.inject(EmoteMenu);
this.ChatController = this.fine.define(
diff --git a/src/sites/twitch-twilight/modules/chat/rich_content.jsx b/src/sites/twitch-twilight/modules/chat/rich_content.jsx
index 55c61375..1ef98f83 100644
--- a/src/sites/twitch-twilight/modules/chat/rich_content.jsx
+++ b/src/sites/twitch-twilight/modules/chat/rich_content.jsx
@@ -144,7 +144,7 @@ export default class RichContent extends Module {
return (
{this.renderCard()}
diff --git a/src/sites/twitch-twilight/modules/chat/sub_status.gql b/src/sites/twitch-twilight/modules/chat/sub_status.gql
new file mode 100644
index 00000000..50ff1f70
--- /dev/null
+++ b/src/sites/twitch-twilight/modules/chat/sub_status.gql
@@ -0,0 +1,37 @@
+query FFZ_EmoteMenu_SubStatus($first: Int, $after: Cursor, $criteria: SubscriptionBenefitCriteriaInput!) {
+ currentUser {
+ id
+ subscriptionBenefits(first: $first, after: $after, criteria: $criteria) {
+ pageInfo {
+ hasNextPage
+ }
+ edges {
+ cursor
+ node {
+ id
+ purchasedWithPrime
+ endsAt
+ renewsAt
+ product {
+ id
+ name
+ displayName
+ emoteSetID
+ type
+ owner {
+ id
+ login
+ displayName
+ broadcastBadges {
+ id
+ setID
+ version
+ imageURL(size: NORMAL)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/styles/chat.scss b/src/sites/twitch-twilight/styles/chat.scss
index edd7597d..c4887177 100644
--- a/src/sites/twitch-twilight/styles/chat.scss
+++ b/src/sites/twitch-twilight/styles/chat.scss
@@ -33,4 +33,79 @@
.tw-ellipsis { line-height: 1.4rem }
.chat-card__title { line-height: 1.5rem }
}
+}
+
+
+.ffz--emote-picker {
+ section:not(.filtered) heading {
+ cursor: pointer;
+ }
+
+ .loading {
+ animation: ffz-rotateplane 1.2s infinite linear;
+ }
+
+ .ffz--expiry-info {
+ opacity: 0.5;
+ }
+
+ .emote-picker__tab {
+ border-top: 1px solid transparent;
+ }
+
+ .whispers-thread .emote-picker-and-button & .emote-picker__tab-content {
+ max-height: 30rem;
+ }
+
+ .emote-picker__tab-content {
+ max-height: 50rem;
+ }
+
+ @media only screen and (max-height: 750px) {
+ .emote-picker__tab-content {
+ max-height: calc(100vh - 26rem);
+ }
+ }
+
+ .emote-picker__tab > *,
+ .emote-picker__emote-link > * {
+ pointer-events: none;
+ }
+
+ section:last-of-type {
+ & > div:last-child,
+ & > heading:last-child {
+ border-bottom: none !important;
+ }
+ }
+
+ .emote-picker__emote-link {
+ position: relative;
+ padding: 0.5rem;
+ width: unset;
+ height: unset;
+ min-width: 3.8rem;
+
+ img {
+ vertical-align: middle;
+ }
+
+ &.locked {
+ cursor: not-allowed;
+
+ img {
+ opacity: 0.5;
+ }
+
+ .ffz-i-lock {
+ position: absolute;
+ bottom: 0; right: 0;
+ border-radius: .2rem;
+ font-size: 1rem;
+ background-color: rgba(0,0,0,0.5);
+ color: #fff;
+ }
+
+ }
+ }
}
\ No newline at end of file
diff --git a/src/utilities/compat/fine.js b/src/utilities/compat/fine.js
index 645fa06e..0fa3f409 100644
--- a/src/utilities/compat/fine.js
+++ b/src/utilities/compat/fine.js
@@ -330,7 +330,7 @@ export default class Fine extends Module {
if ( idx !== -1 )
this._waiting.splice(idx, 1);
- this.log.info(`Found class for "${w.name}" at depth ${d.depth}`, d);
+ this.log.info(`Found class for "${w.name}" at depth ${d.depth}`);
w._set(d.cls, d.instances);
}
}
diff --git a/src/utilities/constants.js b/src/utilities/constants.js
index 23f09b05..4bd35d70 100644
--- a/src/utilities/constants.js
+++ b/src/utilities/constants.js
@@ -6,6 +6,33 @@ export const SERVER = DEBUG ? '//localhost:8000' : 'https://cdn.frankerfacez.com
export const CLIENT_ID = 'a3bc9znoz6vi8ozsoca0inlcr4fcvkl';
export const API_SERVER = '//api.frankerfacez.com';
+export const TWITCH_EMOTE_BASE = '//static-cdn.jtvnw.net/emoticons/v1/';
+
+export const KNOWN_CODES = {
+ '#-?[\\\\/]': '#-/',
+ ':-?(?:7|L)': ':-7',
+ '\\<\\;\\]': '<]',
+ '\\:-?(S|s)': ':-S',
+ '\\:-?\\\\': ':-\\',
+ '\\:\\>\\;': ':>',
+ 'B-?\\)': 'B-)',
+ '\\:-?[z|Z|\\|]': ':-Z',
+ '\\:-?\\)': ':-)',
+ '\\:-?\\(': ':-(',
+ '\\:-?(p|P)': ':-P',
+ '\\;-?(p|P)': ';-P',
+ '\\<\\;3': '<3',
+ '\\:-?[\\\\/]': ':-/',
+ '\\;-?\\)': ';-)',
+ 'R-?\\)': 'R-)',
+ '[oO](_|\\.)[oO]': 'O.o',
+ '[o|O](_|\\.)[o|O]': 'O.o',
+ '\\:-?D': ':-D',
+ '\\:-?(o|O)': ':-O',
+ '\\>\\;\\(': '>(',
+ 'Gr(a|e)yFace': 'GrayFace'
+};
+
export const WS_CLUSTERS = {
Production: [
['wss://catbag.frankerfacez.com/', 0.25],
diff --git a/src/utilities/object.js b/src/utilities/object.js
index a36f787b..84480f9a 100644
--- a/src/utilities/object.js
+++ b/src/utilities/object.js
@@ -39,6 +39,39 @@ export function timeout(promise, delay) {
}
+/**
+ * Make sure that a given asynchronous function is only called once
+ * at a time.
+ */
+
+export function once(fn) {
+ let waiters;
+
+ return function(...args) {
+ return new Promise(async (s,f) => {
+ if ( waiters )
+ return waiters.push([s,f]);
+
+ waiters = [[s,f]];
+ let result;
+ try {
+ result = await fn.call(this, ...args); // eslint-disable-line no-invalid-this
+ } catch(err) {
+ for(const w of waiters)
+ w[1](err);
+ waiters = null;
+ return;
+ }
+
+ for(const w of waiters)
+ w[0](result);
+
+ waiters = null;
+ })
+ }
+}
+
+
/**
* Check that two arrays are the same length and that each array has the same
* items in the same indices.
@@ -59,6 +92,18 @@ export function array_equals(a, b) {
}
+export function set_equals(a,b) {
+ if ( !(a instanceof Set) || !(b instanceof Set) || a.size !== b.size )
+ return false;
+
+ for(const v of a)
+ if ( ! b.has(v) )
+ return false;
+
+ return true;
+}
+
+
/**
* Special logic to ensure that a target object is matched by a filter.
* @param {object} filter The filter object
diff --git a/src/utilities/tooltip.js b/src/utilities/tooltip.js
index 46559b8a..0bd88a4c 100644
--- a/src/utilities/tooltip.js
+++ b/src/utilities/tooltip.js
@@ -193,6 +193,8 @@ export class Tooltip {
// Set this early in case content uses it early.
tip.update = () => tip._update(); // tip.popper && tip.popper.scheduleUpdate();
+ tip.show = () => this.show(tip);
+ tip.hide = () => this.hide(tip);
tip.rerender = () => {
if ( tip.visible ) {
this.hide(tip);
diff --git a/styles/icons.scss b/styles/icons.scss
index 1dfb358e..788c5c9c 100644
--- a/styles/icons.scss
+++ b/styles/icons.scss
@@ -109,11 +109,18 @@
.ffz-i-pencil:before { content: '\e81a'; } /* '' */
.ffz-i-info:before { content: '\e81b'; } /* '' */
.ffz-i-help:before { content: '\e81c'; } /* '' */
+.ffz-i-calendar:before { content: '\e81d'; } /* '' */
+.ffz-i-left-dir:before { content: '\e81e'; } /* '' */
+.ffz-i-inventory:before { content: '\e81f'; } /* '' */
+.ffz-i-lock:before { content: '\e820'; } /* '' */
+.ffz-i-lock-open:before { content: '\e821'; } /* '' */
+.ffz-i-arrows-cw:before { content: '\e822'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */
.ffz-i-gauge:before { content: '\f0e4'; } /* '' */
.ffz-i-download-cloud:before { content: '\f0ed'; } /* '' */
.ffz-i-upload-cloud:before { content: '\f0ee'; } /* '' */
.ffz-i-keyboard:before { content: '\f11c'; } /* '' */
+.ffz-i-calendar-empty:before { content: '\f133'; } /* '' */
.ffz-i-ellipsis-vert:before { content: '\f142'; } /* '' */
.ffz-i-twitch:before { content: '\f1e8'; } /* '' */
.ffz-i-bell-off:before { content: '\f1f7'; } /* '' */