diff --git a/package.json b/package.json
index d8f506a1..9f1808f6 100755
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
- "version": "4.26.0",
+ "version": "4.27.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true,
"license": "Apache-2.0",
diff --git a/src/modules/chat/emotes.js b/src/modules/chat/emotes.js
index 0bbfa1f6..a7f1bcd9 100644
--- a/src/modules/chat/emotes.js
+++ b/src/modules/chat/emotes.js
@@ -1170,15 +1170,15 @@ function determineEmoteType(emote) {
function determineSetType(set) {
- const id = parseInt(set.id, 10);
+ const id = /^\d+$/.test(set.id) ? parseInt(set.id, 10) : null;
- if ( TWITCH_GLOBAL_SETS.includes(id) )
+ if ( id && TWITCH_GLOBAL_SETS.includes(id) )
return EmoteTypes.Global;
- if ( TWITCH_POINTS_SETS.includes(id) )
+ if ( id && TWITCH_POINTS_SETS.includes(id) )
return EmoteTypes.ChannelPoints;
- if ( TWITCH_PRIME_SETS.includes(id) )
+ if ( id && TWITCH_PRIME_SETS.includes(id) )
return EmoteTypes.Prime;
if ( id == 300374282 )
diff --git a/src/modules/chat/tokenizers.jsx b/src/modules/chat/tokenizers.jsx
index 73208127..9822ece3 100644
--- a/src/modules/chat/tokenizers.jsx
+++ b/src/modules/chat/tokenizers.jsx
@@ -1361,6 +1361,11 @@ export const AddonEmotes = {
else if ( type === EmoteTypes.ChannelPoints )
source = this.i18n.t('emote.points', 'Channel Points Emote');
+ else if ( type === EmoteTypes.Follower && emote_set.owner?.login )
+ source = this.i18n.t('emote.follower', 'Follower Emote ({source})', {
+ source: emote_set.owner.displayName || emote_set.owner.login
+ });
+
else if ( type === EmoteTypes.Subscription && emote_set.owner?.login )
source = this.i18n.t('tooltip.channel', 'Channel: {source}', {
source: emote_set.owner.displayName || emote_set.owner.login
diff --git a/src/modules/metadata.jsx b/src/modules/metadata.jsx
index ae092ba7..9cb941c4 100644
--- a/src/modules/metadata.jsx
+++ b/src/modules/metadata.jsx
@@ -45,7 +45,12 @@ export default class Metadata extends Module {
path: 'Channel > Metadata >> Player',
title: 'Playback Statistics',
description: 'Show the current stream delay, with playback rate and dropped frames in the tooltip.',
- component: 'setting-check-box'
+ component: 'setting-check-box',
+
+ getExtraTerms: () => ([
+ 'latency',
+ 'bitrate'
+ ])
},
changed: () => this.updateMetadata('player-stats')
diff --git a/src/sites/twitch-twilight/modules/chat/emote_menu.jsx b/src/sites/twitch-twilight/modules/chat/emote_menu.jsx
index ce3fdc49..191fd109 100644
--- a/src/sites/twitch-twilight/modules/chat/emote_menu.jsx
+++ b/src/sites/twitch-twilight/modules/chat/emote_menu.jsx
@@ -4,7 +4,7 @@
// Chat Emote Menu
// ============================================================================
-import {has, get, once, maybe_call, set_equals, getTwitchEmoteURL, getTwitchEmoteSrcSet} from 'utilities/object';
+import {has, get, once, maybe_call, set_equals, getTwitchEmoteURL, getTwitchEmoteSrcSet, deep_equals} from 'utilities/object';
import {TWITCH_GLOBAL_SETS, EmoteTypes, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS, WEBKIT_CSS as WEBKIT, IS_OSX, KNOWN_CODES, REPLACEMENT_BASE, REPLACEMENTS, KEYS} from 'utilities/constants';
import {HIDDEN_CATEGORIES, CATEGORIES, CATEGORY_SORT, IMAGE_PATHS} from 'src/modules/chat/emoji';
import {ClickOutside} from 'utilities/dom';
@@ -828,6 +828,8 @@ export default class EmoteMenu extends Module {
if ( emote_lock ) {
if ( emote_lock.id === 'cheer' ) {
sellout = t.i18n.t('emote-menu.emote-cheer', 'Cheer an additional {bits_remaining,number} bit{bits_remaining,en_plural} to unlock this emote.', emote_lock);
+ } else if ( emote_lock.id === 'follower' ) {
+ sellout = t.i18n.t('emote-menu.emote-follower', 'Follow {user} to unlock this emote in their channel.', emote_lock);
} else if ( data.all_locked )
sellout = t.i18n.t('emote-menu.emote-sub', 'Subscribe for {price} to unlock this emote.', emote_lock);
else
@@ -897,7 +899,7 @@ export default class EmoteMenu extends Module {
{! visibility && has_modifiers &&
}
{! visibility && emote.favorite && }
- {! visibility && locked && }
+ {! visibility && locked && }
{hidden && }
)
}
@@ -919,7 +921,7 @@ export default class EmoteMenu extends Module {
t.i18n.t('emote-menu.sub-unlock', 'Subscribe for {price} to unlock {count,number} emote{count,en_plural}', {price: lock.price, count: lock.emotes.size}) :
t.i18n.t('emote-menu.sub-basic', 'Subscribe to unlock some emotes')}
- {locks.map(lock => (
lock.hide_button ? null : (
{emote.favorite &&
}
- {locked &&
}
+ {locked &&
}
)
}
}
@@ -1673,6 +1675,19 @@ export default class EmoteMenu extends Module {
}
+ // Before anything, identify the follower sets.
+ const user = props.channel_data && props.channel_data.user,
+ products = user && user.subscriptionProducts,
+ local_sets = user && props.channel_data?.channel?.localEmoteSets,
+ is_following = user && user.self?.follower != null,
+ bits = user?.cheer?.badgeTierEmotes;
+
+ const follower_sets = new Set();
+ if ( Array.isArray(local_sets) )
+ for(const local of local_sets)
+ if ( local?.id )
+ follower_sets.add(local.id);
+
// Start with the All tab. Some data calculated for
// all is re-used for the Channel tab.
@@ -1697,9 +1712,10 @@ export default class EmoteMenu extends Module {
const set_id = emote_set.id,
int_id = parseInt(set_id, 10),
owner = emote_set.owner,
- is_bits = parseInt(emote_set.id, 10) > 5e8,
+ is_follower = follower_sets.has(set_id),
+ is_bits = ! is_follower && int_id > 5e8,
is_points = TWITCH_POINTS_SETS.includes(int_id) || owner?.login === 'channel_points',
- chan = is_points ? null : owner,
+ chan = is_follower ? user : is_points ? null : owner,
set_data = data[set_id],
is_current_bits = is_bits && owner && owner.id == props?.channel_data?.user?.id;
@@ -1721,7 +1737,17 @@ export default class EmoteMenu extends Module {
if ( title ) {
key = `twitch-${chan?.id}`;
- if ( is_bits )
+ if ( is_follower )
+ t.emotes.setTwitchSetChannel(set_id, {
+ id: set_id,
+ type: EmoteTypes.Follower,
+ owner: {
+ id: chan.id,
+ login: chan.login,
+ displayName: chan.displayName
+ }
+ });
+ else if ( is_bits )
t.emotes.setTwitchSetChannel(set_id, {
id: set_id,
type: EmoteTypes.BitsTier,
@@ -1788,6 +1814,10 @@ export default class EmoteMenu extends Module {
} else
title = t.i18n.t('emote-menu.unknown-set', 'Set #{set_id}', {set_id})
+ // Do not display follower emotes the user does not have.
+ if ( is_follower )
+ continue;
+
let section, emotes;
if ( grouped_sets[key] ) {
@@ -1907,13 +1937,10 @@ export default class EmoteMenu extends Module {
// Now we handle the current Channel's emotes.
- const user = props.channel_data && props.channel_data.user,
- products = user && user.subscriptionProducts,
- bits = user?.cheer?.badgeTierEmotes;
-
- if ( Array.isArray(products) || Array.isArray(bits) ) {
+ if ( Array.isArray(local_sets) || Array.isArray(products) || Array.isArray(bits) ) {
const badge = t.badges.getTwitchBadge('subscriber', '0', user.id, user.login),
emotes = [],
+ unlockable_emotes = new Set,
locks = {},
section = {
sort_key: -10,
@@ -1929,6 +1956,67 @@ export default class EmoteMenu extends Module {
all_locked: true
};
+ if ( Array.isArray(local_sets) ) {
+ for(const local of local_sets) {
+ if ( ! local || ! Array.isArray(local.emotes) )
+ continue;
+
+ const set_id = local.id;
+
+ let lock_set;
+
+ // If we're not following, we can't use the emote
+ // so lock it.
+ if ( ! is_following )
+ locks[set_id] = {
+ set_id,
+ id: 'follower',
+ user: user?.displayName || user?.login,
+ hide_button: true,
+ emotes: lock_set = new Set()
+ }
+ else
+ section.all_locked = false;
+
+ let order = 0;
+ for(const emote of local.emotes) {
+ if ( ! emote || ! emote.id || ! emote.token )
+ continue;
+
+ const id = emote.id,
+ name = KNOWN_CODES[emote.token] || emote.token,
+ seen = twitch_seen.has(id),
+ is_fav = twitch_favorites.includes(id);
+
+ const em = {
+ provider: 'twitch',
+ id,
+ set_id,
+ name,
+ order: order++,
+ src: getTwitchEmoteURL(id, 1, false),
+ srcSet: getTwitchEmoteSrcSet(id, false),
+ animSrc: getTwitchEmoteURL(id, 1, true),
+ animSrcSet: getTwitchEmoteSrcSet(id, true),
+ favorite: is_fav,
+ hidden: twitch_hidden.includes(id),
+ locked: ! is_following,
+ lock_icon: 'heart'
+ };
+
+ emotes.push(em);
+
+ if ( is_fav && ! seen )
+ favorites.push(em);
+
+ twitch_seen.add(id);
+
+ if ( lock_set )
+ lock_set.add(id);
+ }
+ }
+ }
+
if ( Array.isArray(products) ) {
for(const product of products) {
if ( ! product || ! Array.isArray(product.emotes) )
@@ -1957,7 +2045,7 @@ export default class EmoteMenu extends Module {
id: product.id,
price: product.price || TIERS[product.tier],
url: product.url,
- emotes: lock_set = new Set(emotes.map(e => e.id))
+ emotes: lock_set = new Set(unlockable_emotes)
}
else
section.all_locked = false;
@@ -1997,8 +2085,10 @@ export default class EmoteMenu extends Module {
twitch_seen.add(id);
- if ( lock_set )
+ if ( lock_set ) {
+ unlockable_emotes.add(id);
lock_set.add(id);
+ }
}
}
}
@@ -2216,16 +2306,32 @@ export default class EmoteMenu extends Module {
componentDidUpdate(old_props) {
- if ( this.props.visible && ! old_props.visible )
+ if ( this.props.visible && ! old_props.visible ) {
this.loadData();
+ return;
+ }
- if ( this.props.channel_data !== old_props.channel_data ||
- this.props.emote_data !== old_props.emote_data ||
+ const cd = this.props.channel_data,
+ old_cd = old_props.channel_data,
+ cd_diff = cd?.user !== old_cd?.user || cd?.channel !== old_cd?.channel,
+
+ // emote_data is rebuilt by Twitch a lot so we can't
+ // rely on object equality. Use a deep equality check. It's
+ // going to be slower, but it's still faster than rebuilding
+ // our entire data structure when nothing actually changed.
+ ed = this.props.emote_data,
+ old_ed = old_props.emote_data,
+ ed_diff = ! deep_equals(ed?.emoteSets, old_ed?.emoteSets) ||
+ ! deep_equals(ed?.emoteMap, old_ed?.emoteMap);
+
+ if ( cd_diff || ed_diff ||
this.props.user_id !== old_props.user_id ||
this.props.channel_id !== old_props.channel_id ||
this.props.loading !== old_props.loading ||
- this.props.error !== old_props.error )
+ this.props.error !== old_props.error ) {
+ t.log.debug('Updating emote menu data. cd', cd_diff, ', ed', ed_diff);
this.rebuildData();
+ }
}
renderError() {
diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js
index f1d91f0d..61eccb1d 100644
--- a/src/sites/twitch-twilight/modules/chat/index.js
+++ b/src/sites/twitch-twilight/modules/chat/index.js
@@ -132,6 +132,7 @@ const CHAT_TYPES = make_enum(
'StandardPayForward',
'CommunityPayForward',
'FirstCheerMessage',
+ 'FirstMessageHighlight',
'BitsBadgeTierMessage',
'InlinePrivateCallout',
'ChannelPointsReward',
diff --git a/src/sites/twitch-twilight/modules/css_tweaks/styles/hide-unfollow-button.scss b/src/sites/twitch-twilight/modules/css_tweaks/styles/hide-unfollow-button.scss
index af8e5e50..6f18a55e 100644
--- a/src/sites/twitch-twilight/modules/css_tweaks/styles/hide-unfollow-button.scss
+++ b/src/sites/twitch-twilight/modules/css_tweaks/styles/hide-unfollow-button.scss
@@ -1,7 +1,3 @@
-.follow-btn__follow-notify-container--following {
- margin-left: 3rem;
-}
-
-.follow-btn__follow-btn--following,.follow-btn--following {
+button[data-test-selector="unfollow-button"] {
display: none !important;
}
\ 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 43129940..9cc77047 100644
--- a/src/sites/twitch-twilight/styles/chat.scss
+++ b/src/sites/twitch-twilight/styles/chat.scss
@@ -417,8 +417,7 @@
opacity: 0.5;
}
- .ffz-i-lock,
- .ffz-i-eye-off {
+ [class^="ffz-i-"] {
position: absolute;
bottom: 0; right: 0;
border-radius: .2rem;
diff --git a/src/utilities/constants.js b/src/utilities/constants.js
index 917eeb13..d8c1534d 100644
--- a/src/utilities/constants.js
+++ b/src/utilities/constants.js
@@ -183,5 +183,6 @@ export const EmoteTypes = make_enum(
'Subscription',
'BitsTier',
'Global',
- 'TwoFactor'
+ 'TwoFactor',
+ 'Follower'
);
\ No newline at end of file