1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00

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.
This commit is contained in:
SirStendec 2018-04-06 21:12:12 -04:00
parent 92130ebac4
commit e6e11fe562
23 changed files with 1423 additions and 26 deletions

View file

@ -1,3 +1,18 @@
<div class="list-header">4.0.0-beta2<span>@65ca9bedbd1b59ec8df4</span> <time datetime="2018-04-06">(2018-04-06)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Added: Emote Menu</li>
<li>Yes, really. You can stop asking now or saying it's broken.</li>
<li>&nbsp;</li>
<li>Fixed: Possible bugs when trying to rebuild CSS for a room that has been unloaded.</li>
<li>Fixed: Typo in rich content hosts preventing links from opening without referral information.</li>
<li>Fixed: Memory leak with tooltips.</li>
<li>Changed: Added several more icons to the icon font.</li>
<li>Changed: Add support for hiding emote sources and displaying an additional message to emote tooltips.</li>
<li>API Added: Methods to get available emote sets along with the provider that added them.</li>
<li>API Added: Events when available emote sets changed.</li>
<li>API Added: Method to load an arbitrary emote set by ID.</li>
</ul>
<div class="list-header">4.0.0-beta1.10<span>@77498dc31e57b48d0549</span> <time datetime="2018-04-03">(2018-04-03)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Added: Rendering support for rich content blocks in chat.</li>

Binary file not shown.

View file

@ -64,6 +64,18 @@
<glyph glyph-name="help" unicode="&#xe81c;" d="M500 82v107q0 8-5 13t-13 5h-107q-8 0-13-5t-5-13v-107q0-8 5-13t13-5h107q8 0 13 5t5 13z m143 375q0 49-31 91t-77 65-95 23q-136 0-207-119-9-13 4-24l74-55q4-4 10-4 9 0 14 7 30 38 48 51 19 14 48 14 27 0 48-15t21-33q0-21-11-34t-38-25q-35-15-65-48t-29-70v-20q0-8 5-13t13-5h107q8 0 13 5t5 13q0 10 12 27t30 28q18 10 28 16t25 19 25 27 16 34 7 45z m214-107q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />
<glyph glyph-name="calendar" unicode="&#xe81d;" d="M71-79h161v161h-161v-161z m197 0h178v161h-178v-161z m-197 197h161v178h-161v-178z m197 0h178v178h-178v-178z m-197 214h161v161h-161v-161z m411-411h179v161h-179v-161z m-214 411h178v161h-178v-161z m428-411h161v161h-161v-161z m-214 197h179v178h-179v-178z m-196 482v161q0 7-6 12t-12 6h-36q-7 0-12-6t-6-12v-161q0-7 6-13t12-5h36q7 0 12 5t6 13z m410-482h161v178h-161v-178z m-214 214h179v161h-179v-161z m214 0h161v161h-161v-161z m18 268v161q0 7-5 12t-13 6h-35q-7 0-13-6t-5-12v-161q0-7 5-13t13-5h35q8 0 13 5t5 13z m215 36v-715q0-29-22-50t-50-21h-786q-29 0-50 21t-21 50v715q0 29 21 50t50 21h72v54q0 37 26 63t63 26h36q37 0 63-26t26-63v-54h214v54q0 37 27 63t63 26h35q37 0 64-26t26-63v-54h71q29 0 50-21t22-50z" horiz-adv-x="928.6" />
<glyph glyph-name="left-dir" unicode="&#xe81e;" d="M357 600v-500q0-14-10-25t-26-11-25 11l-250 250q-10 11-10 25t10 25l250 250q11 11 25 11t26-11 10-25z" horiz-adv-x="357.1" />
<glyph glyph-name="inventory" unicode="&#xe81f;" d="M444 406h167l0-239 111 39v200h111 56v-167l-22-56-423-166h-55v444l55-55z m-55 55v-444h-56l-311 166-22 56v167h56v-167l222-111 55 55v223l56 55z m444 56h-111l0 144-111 33v-177h-167l-55-56v333h55l423-111 22-55v-111h-56z m-833 0l0 111 22 55 311 111h56v-333l-56 56v216l-277-100v-116h-56z m83 150l-66 77 11 45 183 61 28-6 83-88-239-89z m6 83l22-33 128 44-28 33-122-44z" horiz-adv-x="1000" />
<glyph glyph-name="lock" unicode="&#xe820;" d="M179 421h285v108q0 59-42 101t-101 41-101-41-41-101v-108z m464-53v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h17v108q0 102 74 176t176 74 177-74 73-176v-108h18q23 0 38-15t16-38z" horiz-adv-x="642.9" />
<glyph glyph-name="lock-open" unicode="&#xe821;" d="M929 529v-143q0-15-11-25t-25-11h-36q-14 0-25 11t-11 25v143q0 59-41 101t-101 41-101-41-42-101v-108h53q23 0 38-15t16-38v-322q0-22-16-37t-38-16h-535q-23 0-38 16t-16 37v322q0 22 16 38t38 15h375v108q0 103 73 176t177 74 176-74 74-176z" horiz-adv-x="928.6" />
<glyph glyph-name="arrows-cw" unicode="&#xe822;" d="M843 261q0-3 0-4-36-150-150-243t-267-93q-81 0-157 31t-136 88l-72-72q-11-11-25-11t-25 11-11 25v250q0 14 11 25t25 11h250q14 0 25-11t10-25-10-25l-77-77q40-36 90-57t105-20q74 0 139 37t104 99q6 10 30 66 4 13 16 13h107q8 0 13-6t5-12z m14 446v-250q0-14-10-25t-26-11h-250q-14 0-25 11t-10 25 10 25l77 77q-82 77-194 77-75 0-140-37t-104-99q-6-10-29-66-5-13-17-13h-111q-7 0-13 6t-5 12v4q36 150 151 243t268 93q81 0 158-31t137-88l72 72q11 11 25 11t26-11 10-25z" horiz-adv-x="857.1" />
<glyph glyph-name="twitter" unicode="&#xf099;" d="M904 622q-37-54-90-93 0-8 0-23 0-73-21-145t-64-139-103-117-144-82-181-30q-151 0-276 81 19-2 43-2 126 0 224 77-59 1-105 36t-64 89q19-3 34-3 24 0 48 6-63 13-104 62t-41 115v2q38-21 82-23-37 25-59 64t-22 86q0 49 25 91 68-83 164-133t208-55q-5 21-5 41 0 75 53 127t127 53q79 0 132-57 61 12 115 44-21-64-80-100 52 6 104 28z" horiz-adv-x="928.6" />
<glyph glyph-name="gauge" unicode="&#xf0e4;" d="M214 207q0 30-21 51t-50 21-51-21-21-51 21-50 51-21 50 21 21 50z m107 250q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m239-268l57 213q3 14-5 27t-21 16-27-3-17-22l-56-213q-33-3-60-25t-35-55q-11-43 11-81t66-50 81 11 50 66q9 33-4 65t-40 51z m369 18q0 30-21 51t-51 21-50-21-21-51 21-50 50-21 51 21 21 50z m-358 357q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m250-107q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m179-250q0-145-79-269-10-17-30-17h-782q-20 0-30 17-79 123-79 269 0 102 40 194t106 160 160 107 194 39 194-39 160-107 106-160 40-194z" horiz-adv-x="1000" />
@ -74,6 +86,8 @@
<glyph glyph-name="keyboard" unicode="&#xf11c;" d="M214 198v-53q0-9-9-9h-53q-9 0-9 9v53q0 9 9 9h53q9 0 9-9z m72 143v-53q0-9-9-9h-125q-9 0-9 9v53q0 9 9 9h125q9 0 9-9z m-72 143v-54q0-9-9-9h-53q-9 0-9 9v54q0 9 9 9h53q9 0 9-9z m572-286v-53q0-9-9-9h-482q-9 0-9 9v53q0 9 9 9h482q9 0 9-9z m-357 143v-53q0-9-9-9h-54q-9 0-9 9v53q0 9 9 9h54q9 0 9-9z m-72 143v-54q0-9-9-9h-53q-9 0-9 9v54q0 9 9 9h53q9 0 9-9z m214-143v-53q0-9-8-9h-54q-9 0-9 9v53q0 9 9 9h54q8 0 8-9z m-71 143v-54q0-9-9-9h-53q-9 0-9 9v54q0 9 9 9h53q9 0 9-9z m214-143v-53q0-9-9-9h-53q-9 0-9 9v53q0 9 9 9h53q9 0 9-9z m215-143v-53q0-9-9-9h-54q-9 0-9 9v53q0 9 9 9h54q9 0 9-9z m-286 286v-54q0-9-9-9h-54q-9 0-9 9v54q0 9 9 9h54q9 0 9-9z m143 0v-54q0-9-9-9h-54q-9 0-9 9v54q0 9 9 9h54q9 0 9-9z m143 0v-196q0-9-9-9h-125q-9 0-9 9v53q0 9 9 9h62v134q0 9 9 9h54q9 0 9-9z m71-420v500h-929v-500h929z m71 500v-500q0-29-20-50t-51-21h-929q-29 0-50 21t-21 50v500q0 30 21 51t50 21h929q30 0 51-21t20-51z" horiz-adv-x="1071.4" />
<glyph glyph-name="calendar-empty" unicode="&#xf133;" d="M71-79h786v572h-786v-572z m215 679v161q0 8-5 13t-13 5h-36q-8 0-13-5t-5-13v-161q0-8 5-13t13-5h36q8 0 13 5t5 13z m428 0v161q0 8-5 13t-13 5h-35q-8 0-13-5t-5-13v-161q0-8 5-13t13-5h35q8 0 13 5t5 13z m215 36v-715q0-29-22-50t-50-21h-786q-29 0-50 21t-21 50v715q0 29 21 50t50 21h72v54q0 37 26 63t63 26h36q37 0 63-26t26-63v-54h214v54q0 37 27 63t63 26h35q37 0 64-26t26-63v-54h71q29 0 50-21t22-50z" horiz-adv-x="928.6" />
<glyph glyph-name="ellipsis-vert" unicode="&#xf142;" d="M214 154v-108q0-22-15-37t-38-16h-107q-23 0-38 16t-16 37v108q0 22 16 38t38 15h107q22 0 38-15t15-38z m0 285v-107q0-22-15-38t-38-15h-107q-23 0-38 15t-16 38v107q0 23 16 38t38 16h107q22 0 38-16t15-38z m0 286v-107q0-22-15-38t-38-16h-107q-23 0-38 16t-16 38v107q0 22 16 38t38 16h107q22 0 38-16t15-38z" horiz-adv-x="214.3" />
<glyph glyph-name="twitch" unicode="&#xf1e8;" d="M500 608v-242h-81v242h81z m222 0v-242h-81v242h81z m0-424l141 141v444h-666v-585h182v-121l121 121h222z m222 666v-565l-242-242h-182l-121-122h-121v122h-222v646l61 161h827z" horiz-adv-x="1000" />

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -84,6 +84,35 @@ export class TranslationManager extends Module {
}
toHumanTime(duration, factor = 1) {
// TODO: Make this better. Make all time handling better in fact.
duration = Math.floor(duration);
const years = Math.floor((duration * factor) / 31536000) / factor;
if ( years >= 1 )
return this.t('human-time.years', '%{count} year%{count|en_plural}', years);
const days = Math.floor((duration %= 31536000) / 86400);
if ( days >= 1 )
return this.t('human-time.days', '%{count} day%{count|en_plural}', days);
const hours = Math.floor((duration %= 86400) / 3600);
if ( hours >= 1 )
return this.t('human-time.hours', '%{count} hour%{count|en_plural}', hours);
const minutes = Math.floor((duration %= 3600) / 60);
if ( minutes >= 1 )
return this.t('human-time.minutes', '%{count} minute%{count|en_plural}', minutes);
const seconds = duration % 60;
if ( seconds >= 1 )
return this.t('human-time.seconds', '%{count} second%{count|en_plural}', seconds);
return this.t('human-time.none', 'less than a second');
}
async loadLocale(locale) {
/*if ( locale === 'en' )
return {};

View file

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

View file

@ -10,6 +10,8 @@ import {has, timeout, SourcedSet} from 'utilities/object';
import {CLIENT_ID, API_SERVER} from 'utilities/constants';
const EXTRA_INVENTORY = [33563];
const MODIFIERS = {
59847: {
modifier_offset: '0 15px 15px 0',
@ -58,13 +60,21 @@ export default class Emotes extends Module {
this.inject('socket');
this.inject('settings');
this.twitch_inventory_sets = [];
this.twitch_inventory_sets = new Set(EXTRA_INVENTORY);
this.__twitch_emote_to_set = new Map;
this.__twitch_set_to_channel = new Map;
this.default_sets = new SourcedSet;
this.global_sets = new SourcedSet;
this.providers = new Map;
this.providers.set('featured', {
name: 'Featured',
i18n_key: 'emote-menu.featured',
sort_key: 75
})
this.emote_sets = {};
this._set_refs = {};
this._set_timers = {};
@ -98,11 +108,40 @@ export default class Emotes extends Module {
}
}
this.socket.on(':command:follow_sets', this.updateFollowSets, this);
this.loadGlobalSets();
this.loadTwitchInventory();
}
// ========================================================================
// Featured Sets
// ========================================================================
updateFollowSets(data) {
for(const room_login in data)
if ( has(data, room_login) ) {
const room = this.parent.getRoom(null, room_login, true),
new_sets = data[room_login] || [],
emote_sets = room.emote_sets,
providers = emote_sets._sources;
if ( providers && providers.has('featured') )
for(const item of providers.get('featured'))
if ( ! new_sets.includes(item) )
room.removeSet('featured', item);
for(const set_id of new_sets) {
room.addSet('featured', set_id);
if ( ! this.emote_sets[set_id] )
this.loadSet(set_id);
}
}
}
// ========================================================================
// Access
// ========================================================================
@ -124,6 +163,41 @@ export default class Emotes extends Module {
.map(set_id => this.emote_sets[set_id]);
}
_withSources(out, seen, emote_sets) { // eslint-disable-line class-methods-use-this
if ( ! emote_sets._sources )
return;
for(const [provider, data] of emote_sets._sources)
for(const item of data)
if ( ! seen.has(item) ) {
out.push([item, provider]);
seen.add(item);
}
return out;
}
getRoomSetIDsWithSources(user_id, user_login, room_id, room_login) {
const room = this.parent.getRoom(room_id, room_login, true),
room_user = room && room.getUser(user_id, user_login, true);
if ( ! room )
return [];
const out = [], seen = new Set;
this._withSources(out, seen, room.emote_sets);
if ( room_user )
this._withSources(out, seen, room_user);
return out;
}
getRoomSetsWithSources(user_id, user_login, room_id, room_login) {
return this.getRoomSetIDsWithSources(user_id, user_login, room_id, room_login)
.map(([set_id, source]) => [this.emote_sets[set_id], source]);
}
getRoomSetIDs(user_id, user_login, room_id, room_login) {
const room = this.parent.getRoom(room_id, room_login, true),
room_user = room && room.getUser(user_id, user_login, true);
@ -142,6 +216,22 @@ export default class Emotes extends Module {
.map(set_id => this.emote_sets[set_id]);
}
getGlobalSetIDsWithSources(user_id, user_login) {
const user = this.parent.getUser(user_id, user_login, true),
out = [], seen = new Set;
this._withSources(out, seen, this.default_sets);
if ( user )
this._withSources(out, seen, user.emote_sets);
return out;
}
getGlobalSetsWithSources(user_id, user_login) {
return this.getGlobalSetIDsWithSources(user_id, user_login)
.map(([set_id, source]) => [this.emote_sets[set_id], source]);
}
getGlobalSetIDs(user_id, user_login) {
const user = this.parent.getUser(user_id, user_login, true);
if ( ! user )
@ -171,28 +261,30 @@ export default class Emotes extends Module {
// ========================================================================
addDefaultSet(provider, set_id, data) {
const had_set = this.default_sets.includes(set_id);
let changed = false;
if ( ! this.default_sets.sourceIncludes(provider, set_id) ) {
this.default_sets.push(provider, set_id);
this.refSet(set_id);
changed = true;
}
if ( data )
this.loadSetData(set_id, data);
if ( ! had_set )
this.emit(':update-default-sets');
if ( changed )
this.emit(':update-default-sets', provider, set_id, true);
}
removeDefaultSet(provider, set_id) {
const had_set = this.default_sets.includes(set_id);
let changed = false;
if ( this.default_sets.sourceIncludes(provider, set_id) ) {
this.default_sets.remove(provider, set_id);
this.unrefSet(set_id);
changed = true;
}
if ( had_set && ! this.default_sets.includes(set_id) )
this.emit(':update-default-sets');
if ( changed )
this.emit(':update-default-sets', provider, set_id, false);
}
refSet(set_id) {
@ -252,7 +344,41 @@ export default class Emotes extends Module {
}
loadSetUsers(data) {
async loadSet(set_id, suppress_log = false, tries = 0) {
let response, data;
try {
response = await fetch(`${API_SERVER}/v1/set/${set_id}`)
} catch(err) {
tries++;
if ( tries < 10 )
return setTimeout(() => this.loadGlobalSets(tries), 500 * tries);
this.log.error(`Error loading data for set "${set_id}".`, err);
return false;
}
if ( ! response.ok )
return false;
try {
data = await response.json();
} catch(err) {
this.log.error(`Error parsing data for set "${set_id}".`, err);
return false;
}
const set = data.set;
if ( set )
this.loadSetData(set.id, set, suppress_log);
if ( data.users )
this.loadSetUsers(data.users);
return true;
}
loadSetUsers(data, suppress_log = false) {
for(const set_id in data)
if ( has(data, set_id) ) {
const emote_set = this.emote_sets[set_id],
@ -262,7 +388,8 @@ export default class Emotes extends Module {
this.parent.getUser(undefined, login)
.addSet('ffz-global', set_id);
this.log.info(`Added "${emote_set ? emote_set.title : set_id}" emote set to ${users.length} users.`);
if ( ! suppress_log )
this.log.info(`Added "${emote_set ? emote_set.title : set_id}" emote set to ${users.length} users.`);
}
}
@ -283,6 +410,7 @@ export default class Emotes extends Module {
new_ems = data.emotes = {},
css = [];
data.id = set_id;
data.emoticons = undefined;
for(const emote of ems) {
@ -413,7 +541,11 @@ export default class Emotes extends Module {
return;
}
this.twitch_inventory_sets = data.emoticon_sets ? Object.keys(data.emoticon_sets) : [];
const sets = this.twitch_inventory_sets = new Set(EXTRA_INVENTORY);
for(const set in data.emoticon_sets)
if ( has(data.emoticon_sets, set) )
sets.add(parseInt(set, 10));
this.log.info('Twitch Inventory Sets:', this.twitch_inventory_sets);
}

View file

@ -240,19 +240,25 @@ export default class Room {
// ========================================================================
addSet(provider, set_id, data) {
let changed = false;
if ( ! this.emote_sets.sourceIncludes(provider, set_id) ) {
this.emote_sets.push(provider, set_id);
this.manager.emotes.refSet(set_id);
changed = true;
}
if ( data )
this.manager.emotes.loadSetData(set_id, data);
if ( changed )
this.manager.emotes.emit(':update-room-sets', this, provider, set_id, true);
}
removeSet(provider, set_id) {
if ( this.emote_sets.sourceIncludes(provider, set_id) ) {
this.emote_sets.remove(provider, set_id);
this.manager.emotes.unrefSet(set_id);
this.manager.emotes.emit(':update-room-sets', this, provider, set_id, false);
}
}
@ -297,6 +303,8 @@ export default class Room {
}
buildModBadgeCSS() {
if ( this.destroyed )
return;
if ( ! this.data || ! this.data.mod_urls || ! this.manager.context.get('chat.badges.custom-mod') )
return this.style.delete('mod-badge');
@ -321,6 +329,8 @@ export default class Room {
}
buildBadgeCSS() {
if ( this.destroyed )
return;
if ( ! this.badges )
return this.style.delete('badges');
@ -367,6 +377,8 @@ export default class Room {
}
buildBitsCSS() {
if ( this.destroyed )
return;
if ( ! this.bitsConfig )
return this.style.delete('bits');

View file

@ -477,8 +477,9 @@ export const AddonEmotes = {
},
tooltip(target, tip) {
const provider = target.dataset.provider,
modifiers = target.dataset.modifierInfo;
const ds = target.dataset,
provider = ds.provider,
modifiers = ds.modifierInfo;
let preview, source, owner, mods;
@ -496,7 +497,7 @@ export const AddonEmotes = {
}
if ( provider === 'twitch' ) {
const emote_id = parseInt(target.dataset.id, 10),
const emote_id = parseInt(ds.id, 10),
set_id = this.emotes.getTwitchEmoteSet(emote_id, tip.rerender),
emote_set = set_id != null && this.emotes.getTwitchSetChannel(set_id, tip.rerender);
@ -519,8 +520,8 @@ export const AddonEmotes = {
}
} else if ( provider === 'ffz' ) {
const emote_set = this.emotes.emote_sets[target.dataset.set],
emote = emote_set && emote_set.emotes[target.dataset.id];
const emote_set = this.emotes.emote_sets[ds.set],
emote = emote_set && emote_set.emotes[ds.id];
if ( emote_set )
source = emote_set.source_line || (`${emote_set.source || 'FFZ'} ${emote_set.title || 'Global'}`);
@ -538,6 +539,9 @@ export const AddonEmotes = {
}
}
const name = ds.name || target.alt,
hide_source = ds.noSource === 'true';
return [
preview && this.context.get('tooltip.emote-images') && (<img
class="preview-image"
@ -545,9 +549,9 @@ export const AddonEmotes = {
onLoad={tip.update}
/>),
this.i18n.t('tooltip.emote', 'Emote: %{code}', {code: target.alt}),
(hide_source && ! owner) ? name : this.i18n.t('tooltip.emote', 'Emote: %{code}', {code: ds.name || target.alt}),
source && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05">
! hide_source && source && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05">
{source}
</div>),
@ -555,6 +559,8 @@ export const AddonEmotes = {
{owner}
</div>),
ds.sellout && (<div class="tw-mg-t-05 tw-border-t tw-pd-t-05">{ds.sellout}</div>),
mods && (<div class="tw-pd-t-1">{mods}</div>)
];
},

View file

@ -112,6 +112,7 @@ export default class User {
if ( ! this.emote_sets.sourceIncludes(provider, set_id) ) {
this.emote_sets.push(provider, set_id);
this.manager.emotes.refSet(set_id);
this.manager.emotes.emit(':update-user-sets', this, provider, set_id, true);
return true;
}
}
@ -120,6 +121,7 @@ export default class User {
if ( this.emote_sets.sourceIncludes(provider, set_id) ) {
this.emote_sets.remove(provider, set_id);
this.manager.emotes.unrefSet(set_id);
this.manager.emotes.emit(':update-user-sets', this, provider, set_id, false);
}
}
}

View file

@ -30,15 +30,13 @@
<li>Settings from the old version are not being imported.</li>
<li>Settings cannot be searched.</li>
<li>Emoji aren't displayed.</li>
<li>The emote menu isn't finished.</li>
<li>Tab-completion and advanced input isn't available.</li>
<li>Advanced input isn't available.</li>
</ul>
<p>And the biggest features still under development:</p>
<ul class="tw-mg-b-2">
<li>Emoji Rendering</li>
<li>Emotes Menu</li>
<li>Chat Filtering (Highlighted Words, etc.)</li>
<li>Room Status Indicators</li>
<li>Custom Mod Cards</li>
@ -49,7 +47,6 @@
<li>Portrait Mode</li>
<li>Importing and exporting settings</li>
<li>User Aliases</li>
<li>Rich Content in Chat (aka Clip Embeds)</li>
</ul>
<p>

View file

@ -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 (<t.MenuComponent
visible={this.props.visible}
toggleVisibility={this.props.toggleVisibility}
onClickEmote={this.props.onClickEmote}
channel_data={this.props.channelData}
emote_data={this.props.emoteSetsData}
user_id={this.props.currentUserID}
channel_id={this.props.channelOwnerID}
loading={this.state.gqlLoading}
error={this.state.gqlError}
/>)
}
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 (<button
class={`ffz-tooltip emote-picker__emote-link${locked ? ' locked' : ''}`}
data-tooltip-type="emote"
data-provider={data.provider}
data-id={data.id}
data-set={data.set_id}
data-no-source="true"
data-name={data.name}
aria-label={data.name}
data-locked={data.locked}
data-sellout={sellout}
onClick={!data.locked && this.handleClick}
>
<figure class="emote-picker__emote-figure">
<img
class="emote-picker__emote-image"
src={data.src}
srcSet={data.srcSet}
alt={data.name}
/>
</figure>
{locked && (<figure class="ffz-i-lock" />)}
</button>);
}
}
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 = (<img src={data.image} />);
else
image = (<figure class={`ffz-i-${data.icon || 'zreknarf'}`} />);
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 (<section data-key={data.key} class={this.props.filtered ? 'filtered' : ''}>
<heading class="tw-pd-1 tw-border-b tw-flex" onClick={this.clickHeading}>
{image}
<div class="tw-pd-l-05">
{data.title || t.i18n.t('emote-menu.unknown', 'Unknown Source')}
{calendar && (<span
class={`tw-mg-x-05 ffz--expiry-info ffz-tooltip ffz-i-${calendar.icon}`}
data-tooltip-type="html"
data-title={calendar.message}
/>)}
</div>
<div class="tw-flex-grow-1" />
{data.source || 'FrankerFaceZ'}
<figure class={`tw-pd-l-05 ffz-i-${collapsed ? 'left' : 'down'}-dir`} />
</heading>
{collapsed || this.renderBody()}
</section>)
}
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) && (<t.MenuEmote
key={emote.id}
onClickEmote={this.props.onClickEmote}
data={emote}
locked={emote.locked && (! lock || ! lock.emotes.has(emote.id))}
all_locked={data.all_locked}
lock={data.locks && data.locks[emote.set_id]}
/>));
return (<div class="tw-pd-1 tw-border-b tw-c-background-alt tw-align-center">
{emotes}
{!filtered && this.renderSellout()}
</div>)
}
renderSellout() {
const data = this.props.data;
if ( ! data.all_locked || ! data.locks )
return null;
const lock = data.locks[this.state.unlocked];
return (<div class="tw-mg-1 tw-border-t tw-pd-t-1 tw-mg-b-0">
{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')}
<div class="ffz--sub-buttons tw-mg-t-05">
{Object.values(data.locks).map(lock => (<a
key={lock.price}
class="tw-button"
href={lock.url}
target="_blank"
rel="noopener noreferrer"
data-set-id={lock.set_id}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<span class="tw-button__text">
{lock.price}
</span>
</a>))}
</div>
</div>)
}
}
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 (<div class="tw-align-center tw-pd-1">
<div class="tw-mg-b-1">
<div class="tw-mg-5">
<img src="//cdn.frankerfacez.com/emoticon/26608/2" />
</div>
{t.i18n.t('emote-menu.error', 'There was an error rendering this menu.')}
</div>
<button class="tw-button" onClick={this.forceUpdate}>
<span class="tw-button__text">
{t.i18n.t('error.try-again', 'Try Again')}
</span>
</button>
</div>)
}
renderEmpty() { // eslint-disable-line class-methods-use-this
return (<div class="tw-align-center tw-pd-1">
<div class="tw-mg-5">
<img src="//cdn.frankerfacez.com/emoticon/26608/2" />
</div>
{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.")}
</div>)
}
renderLoading() { // eslint-disable-line class-methods-use-this
return (<div class="tw-align-center tw-pd-1">
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
{t.i18n.t('emote-menu.loading', 'Loading...')}
</div>)
}
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 (<div
class="tw-balloon tw-balloon--md tw-balloon--up tw-balloon--right tw-block tw-absolute ffz--emote-picker"
data-a-target="emote-picker"
>
<div class="tw-border tw-elevation-1 tw-border-radius-small tw-c-background">
<div
class="emote-picker__tab-content scrollable-area"
data-test-selector="scrollable-area-wrapper"
data-simplebar
>
<div class="simplebar-scroll-content">
<div class="simplebar-content">
{loading && this.renderLoading()}
{!loading && sets && sets.map(data => (<t.MenuSection
key={data.key}
data={data}
filtered={this.state.filtered}
onClickEmote={this.props.onClickEmote}
/>))}
{! loading && (! sets || ! sets.length) && this.renderEmpty()}
</div>
</div>
</div>
<div class="emote-picker__controls-container tw-relative">
<div class="tw-border-t tw-pd-1">
<div class="tw-relative">
<input
type="text"
class="tw-input"
onChange={this.handleFilterChange}
onKeyDown={this.handleKeyDown}
placeholder={t.i18n.t('emote-menu.search', 'Search for Emotes')}
value={this.state.filter}
autoFocus
autoCapitalize="off"
autoCorrect="off"
/>
</div>
</div>
<div class="emote-picker__tabs-container tw-flex tw-border-t tw-c-background">
{null && (<div class="ffz-tooltip emote-picker__tab tw-pd-x-1" data-tooltip-type="html" data-title="Favorites">
<figure class="ffz-i-star" />
</div>)}
{this.state.has_channel_page && <div
class={`emote-picker__tab tw-pd-x-1${page === 'channel' ? ' emote-picker__tab--active' : ''}`}
id="emote-picker__channel"
data-page="channel"
onClick={this.clickTab}
>
{t.i18n.t('emote-menu.channel', 'Channel')}
</div>}
<div
class={`emote-picker__tab tw-pd-x-1${page === 'all' ? ' emote-picker__tab--active' : ''}`}
id="emote-picker__all"
data-page="all"
onClick={this.clickTab}
>
{t.i18n.t('emote-menu.all', 'All')}
</div>
<div class="tw-flex-grow-1" />
{!loading && (<div
class="ffz-tooltip emote-picker__tab tw-pd-x-1 tw-mg-r-0"
data-tooltip-type="html"
data-title="Refresh Data"
onClick={this.clickRefresh}
>
<figure class="ffz-i-arrows-cw" />
</div>)}
</div>
</div>
</div>
</div>);
}
}
}
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);

View file

@ -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(

View file

@ -144,7 +144,7 @@ export default class RichContent extends Module {
return (<a
class="chat-card__link"
target="_blank"
rel="noreferer noopener"
rel="noreferrer noopener"
href={this.state.url}
>
{this.renderCard()}

View file

@ -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)
}
}
}
}
}
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}

View file

@ -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',
'\\&lt\\;\\]': '<]',
'\\:-?(S|s)': ':-S',
'\\:-?\\\\': ':-\\',
'\\:\\&gt\\;': ':>',
'B-?\\)': 'B-)',
'\\:-?[z|Z|\\|]': ':-Z',
'\\:-?\\)': ':-)',
'\\:-?\\(': ':-(',
'\\:-?(p|P)': ':-P',
'\\;-?(p|P)': ';-P',
'\\&lt\\;3': '<3',
'\\:-?[\\\\/]': ':-/',
'\\;-?\\)': ';-)',
'R-?\\)': 'R-)',
'[oO](_|\\.)[oO]': 'O.o',
'[o|O](_|\\.)[o|O]': 'O.o',
'\\:-?D': ':-D',
'\\:-?(o|O)': ':-O',
'\\&gt\\;\\(': '>(',
'Gr(a|e)yFace': 'GrayFace'
};
export const WS_CLUSTERS = {
Production: [
['wss://catbag.frankerfacez.com/', 0.25],

View file

@ -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

View file

@ -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);

View file

@ -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'; } /* '' */