From e6e11fe562df6743b197b8608e9b5d5f8c6b41b9 Mon Sep 17 00:00:00 2001 From: SirStendec Date: Fri, 6 Apr 2018 21:12:12 -0400 Subject: [PATCH] Emote Menu? Emote Menu. * Add the emote menu. * Add an option to replace the emote menu icon with the FFZ icon. * Add icons to the icon font. * Add a basic human duration formatting method to i18n. * Add methods to the emotes module to get sets including the providers. * Add a method to the emotes module to load an arbitrary set. * Add a map to the emotes module for identifying providers. * Add new events for when emote sets change. * Add support for loading featured channel emotes. * Add an option to suppress source sets in emote tooltips. * Add an option to display a sellout line in emote tooltips. * Remove emote menu from the WIP section of the home page of the menu. * Fix a typo in rich content. * Remove a bit of logging from fine. * Add helper methods for set comparison and basic debouncing to utilities/object. * Add constants for the emote menu. * Add methods to show/hide a tooltip to the tooltip data object. --- changelog.html | 15 + res/font/ffz-fontello.eot | Bin 16220 -> 18196 bytes res/font/ffz-fontello.svg | 14 + res/font/ffz-fontello.ttf | Bin 16036 -> 18012 bytes res/font/ffz-fontello.woff | Bin 9816 -> 11048 bytes res/font/ffz-fontello.woff2 | Bin 8204 -> 9256 bytes src/i18n.js | 29 + src/main.js | 2 +- src/modules/chat/emotes.js | 152 ++- src/modules/chat/room.js | 12 + src/modules/chat/tokenizers.jsx | 20 +- src/modules/chat/user.js | 2 + .../main_menu/components/home-page.vue | 5 +- .../modules/chat/emote_menu.jsx | 997 ++++++++++++++++++ .../twitch-twilight/modules/chat/index.js | 4 +- .../modules/chat/rich_content.jsx | 2 +- .../modules/chat/sub_status.gql | 37 + src/sites/twitch-twilight/styles/chat.scss | 75 ++ src/utilities/compat/fine.js | 2 +- src/utilities/constants.js | 27 + src/utilities/object.js | 45 + src/utilities/tooltip.js | 2 + styles/icons.scss | 7 + 23 files changed, 1423 insertions(+), 26 deletions(-) create mode 100644 src/sites/twitch-twilight/modules/chat/emote_menu.jsx create mode 100644 src/sites/twitch-twilight/modules/chat/sub_status.gql diff --git a/changelog.html b/changelog.html index 7f0175c3..6322e071 100644 --- a/changelog.html +++ b/changelog.html @@ -1,3 +1,18 @@ +
4.0.0-beta2@65ca9bedbd1b59ec8df4
+ +
4.0.0-beta1.10@77498dc31e57b48d0549

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 (
) + } + } + + 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'; } /* '' */