1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-07-25 20:18:31 +00:00
* Changed: Implemented FFZ rendering of Channel Points redemption notices with no associated messages. (Experiment, 50% roll-out)
* Fixed: Channel not properly detecting the current channel's branding color.
* Fixed: Unable to delete the profile 0 (Default Profile)
* Fixed: Twitch prevented viewer cards from appearing when moved out of the chat area.
* Fixed: `addons` should not block loading while its data loads.
* Fixed: Issue accessing `i18n` before `settings` has fully loaded.
* Fixed: `main_menu` tries to use `i18n` before `i18n` is ready.
* Fixed: Main menu throws error if profiles are changed while main menu is open.
* Fixed: `site` should not block loading waiting for `settings`
* Maintenance: Updated dependencies.

* API Added: Initial support for using IndexedDB to store settings rather than localStorage
* API Added: Messages now have a `highlights` object if they've matched filters, describing which filters they matched.
This commit is contained in:
SirStendec 2020-07-22 21:31:41 -04:00
parent 65a00df2a9
commit 1c2bf202fc
18 changed files with 478 additions and 244 deletions

8
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "frankerfacez",
"version": "4.20.5",
"version": "4.20.14",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -5168,9 +5168,9 @@
}
},
"lodash": {
"version": "4.17.15",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
"integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
"version": "4.17.19",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz",
"integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==",
"dev": true
},
"lodash.tail": {

View file

@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.20.14",
"version": "4.20.15",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"license": "Apache-2.0",
"scripts": {

View file

@ -25,12 +25,18 @@ export default class AddonManager extends Module {
this.inject('settings');
this.inject('i18n');
this.load_requires = ['settings'];
this.has_dev = false;
this.reload_required = false;
this.addons = {};
this.enabled_addons = [];
}
onLoad() {
this._loader = this.loadAddonData();
}
async onEnable() {
this.settings.addUI('add-ons', {
path: 'Add-Ons @{"description": "Add-Ons are additional modules, often written by other people, that can be loaded automatically by FrankerFaceZ to add new capabilities and behaviors to the extension and Twitch.", "profile_warning": false}',
@ -69,16 +75,17 @@ export default class AddonManager extends Module {
this.settings.provider.on('changed', this.onProviderChange, this);
await this.loadAddonData();
this.enabled_addons = this.settings.provider.get('addons.enabled', []);
this._loader.then(() => {
this.enabled_addons = this.settings.provider.get('addons.enabled', []);
// We do not await enabling add-ons because that would delay the
// main script's execution.
for(const id of this.enabled_addons)
if ( this.hasAddon(id) )
this._enableAddon(id);
// We do not await enabling add-ons because that would delay the
// main script's execution.
for(const id of this.enabled_addons)
if ( this.hasAddon(id) )
this._enableAddon(id);
this.emit(':ready');
this.emit(':ready');
});
}
generateLog() {

View file

@ -6,5 +6,13 @@
{"value": true, "weight": 0},
{"value": false, "weight": 100}
]
},
"all_points": {
"name": "Override Channel Points Rendering",
"description": "Override rendering for all channel points messages, even when no message is present.",
"groups": [
{"value": true, "weight": 50},
{"value": false, "weight": 50}
]
}
}

View file

@ -337,7 +337,7 @@ export class TranslationManager extends Module {
get locale() {
return this._.locale;
return this._ && this._.locale;
}
set locale(new_locale) {

View file

@ -277,8 +277,10 @@ export const Mentions = {
recipient: recipient ? recipient.toLowerCase() : ''
});
if ( mentioned )
if ( mentioned ) {
(msg.highlights = (msg.highlights || new Set())).add('mention');
msg.mentioned = true;
}
// Push the remaining text from the token.
text.push(segment.substr(match[0].length));
@ -315,6 +317,7 @@ export const UserHighlights = {
const u = msg.user;
for(const [color, regex] of colors) {
if ( regex.test(u.login) || regex.test(u.displayName) ) {
(msg.highlights = (msg.highlights || new Set())).add('user');
msg.mentioned = true;
if ( color ) {
msg.mention_color = color;
@ -371,6 +374,7 @@ export const BadgeHighlights = {
for(const badge of Object.keys(badges)) {
if ( colors.has(badge) ) {
const color = colors.get(badge);
(msg.highlights = (msg.highlights || new Set())).add('badge');
msg.mentioned = true;
if ( color ) {
msg.mention_color = color;
@ -454,6 +458,7 @@ export const CustomHighlights = {
if ( idx !== nix )
out.push({type: 'text', text: text.slice(idx, nix)});
(msg.highlights = (msg.highlights || new Set())).add('term');
msg.mentioned = true;
msg.mention_color = color || msg.mention_color;

View file

@ -252,7 +252,10 @@ export default class MainMenu extends Module {
if ( this._update_timer )
return;
this._update_timer = setTimeout(() => this.updateLiveMenu(), 250);
this._update_timer = setTimeout(() => {
// Make sure i18n is loaded before we try this.
this.i18n.enable().then(() => this.updateLiveMenu());
}, 250);
}
@ -597,118 +600,144 @@ export default class MainMenu extends Module {
Vue = this.vue.Vue,
settings = this.settings,
context = settings.main_context,
[profiles, profile_keys] = this.getProfiles(),
[profiles, profile_keys] = this.getProfiles();
_c = {
profiles,
profile_keys,
currentProfile: profile_keys[0],
let currentProfile = profile_keys[0];
if ( ! currentProfile ) {
for(let i=profiles.length - 1; i >= 0; i--) {
if ( profiles[i].live ) {
currentProfile = profiles[i];
break;
}
}
has_update: this.has_update,
if ( ! currentProfile )
currentProfile = profiles[0];
}
createProfile: data => {
const profile = settings.createProfile(data);
return t.getProfileProxy(profile, context);
},
const _c = {
profiles,
profile_keys,
currentProfile: profile_keys[0] || profiles[0],
deleteProfile: profile => settings.deleteProfile(profile),
has_update: this.has_update,
getFFZ: () => t.resolve('core'),
createProfile: data => {
const profile = settings.createProfile(data);
return t.getProfileProxy(profile, context);
},
context: {
_users: 0,
deleteProfile: profile => settings.deleteProfile(profile),
profiles: context.__profiles.map(profile => profile.id),
get: key => context.get(key),
uses: key => context.uses(key),
getFFZ: () => t.resolve('core'),
on: (...args) => context.on(...args),
off: (...args) => context.off(...args),
context: {
_users: 0,
order: id => context.order.indexOf(id),
context: deep_copy(context._context),
profiles: context.__profiles.map(profile => profile.id),
get: key => context.get(key),
uses: key => context.uses(key),
_update_profiles(changed) {
const new_list = [],
profiles = context.manager.__profiles;
on: (...args) => context.on(...args),
off: (...args) => context.off(...args),
for(let i=0; i < profiles.length; i++) {
const profile = profile_keys[profiles[i].id];
order: id => context.order.indexOf(id),
context: deep_copy(context._context),
_update_profiles(changed) {
const new_list = [],
profiles = context.manager.__profiles;
for(let i=0; i < profiles.length; i++) {
const profile = profile_keys[profiles[i].id];
if ( profile ) {
profile.order = i;
new_list.push(profile);
}
}
Vue.set(_c, 'profiles', new_list);
Vue.set(_c, 'profiles', new_list);
if ( changed && changed.id === _c.currentProfile.id )
_c.currentProfile = profile_keys[changed.id];
},
if ( changed && changed.id === _c.currentProfile.id )
_c.currentProfile = profile_keys[changed.id];
},
_profile_created(profile) {
Vue.set(profile_keys, profile.id, t.getProfileProxy(profile, context));
this._update_profiles()
},
_profile_created(profile) {
Vue.set(profile_keys, profile.id, t.getProfileProxy(profile, context));
this._update_profiles()
},
_profile_changed(profile) {
Vue.set(profile_keys, profile.id, t.getProfileProxy(profile, context));
this._update_profiles(profile);
},
_profile_changed(profile) {
Vue.set(profile_keys, profile.id, t.getProfileProxy(profile, context));
this._update_profiles(profile);
},
_profile_toggled(profile, val) {
Vue.set(profile_keys[profile.id], 'toggled', val);
this._update_profiles(profile);
},
_profile_toggled(profile, val) {
Vue.set(profile_keys[profile.id], 'toggled', val);
this._update_profiles(profile);
},
_profile_deleted(profile) {
Vue.delete(profile_keys, profile.id);
this._update_profiles();
_profile_deleted(profile) {
Vue.delete(profile_keys, profile.id);
this._update_profiles();
if ( _c.currentProfile.id === profile.id )
_c.currentProfile = profile_keys[0]
},
if ( _c.currentProfile.id === profile.id ) {
_c.currentProfile = profile_keys[0];
if ( ! _c.currentProfile ) {
for(let i=_c.profiles.length - 1; i >= 0; i--) {
if ( _c.profiles[i].live ) {
_c.currentProfile = _c.profiles[i];
break;
}
}
_context_changed() {
this.context = deep_copy(context._context);
const profiles = context.manager.__profiles,
ids = this.profiles = context.__profiles.map(profile => profile.id);
for(let i=0; i < profiles.length; i++) {
const id = profiles[i].id,
profile = profile_keys[id];
profile.live = ids.includes(id);
}
},
_add_user() {
this._users++;
if ( this._users === 1 ) {
settings.on(':profile-toggled', this._profile_toggled, this);
settings.on(':profile-created', this._profile_created, this);
settings.on(':profile-changed', this._profile_changed, this);
settings.on(':profile-deleted', this._profile_deleted, this);
settings.on(':profiles-reordered', this._update_profiles, this);
context.on('context_changed', this._context_changed, this);
context.on('profiles_changed', this._context_changed, this);
this.profiles = context.__profiles.map(profile => profile.id);
}
},
_remove_user() {
this._users--;
if ( this._users === 0 ) {
settings.off(':profile-toggled', this._profile_toggled, this);
settings.off(':profile-created', this._profile_created, this);
settings.off(':profile-changed', this._profile_changed, this);
settings.off(':profile-deleted', this._profile_deleted, this);
settings.off(':profiles-reordered', this._update_profiles, this);
context.off('context_changed', this._context_changed, this);
context.off('profiles_changed', this._context_changed, this);
if ( ! _c.currentProfile )
_c.currentProfile = _c.profiles[0];
}
}
},
_context_changed() {
this.context = deep_copy(context._context);
const profiles = context.manager.__profiles,
ids = this.profiles = context.__profiles.map(profile => profile.id);
for(let i=0; i < profiles.length; i++) {
const id = profiles[i].id,
profile = profile_keys[id];
profile.live = ids.includes(id);
}
},
_add_user() {
this._users++;
if ( this._users === 1 ) {
settings.on(':profile-toggled', this._profile_toggled, this);
settings.on(':profile-created', this._profile_created, this);
settings.on(':profile-changed', this._profile_changed, this);
settings.on(':profile-deleted', this._profile_deleted, this);
settings.on(':profiles-reordered', this._update_profiles, this);
context.on('context_changed', this._context_changed, this);
context.on('profiles_changed', this._context_changed, this);
this.profiles = context.__profiles.map(profile => profile.id);
}
},
_remove_user() {
this._users--;
if ( this._users === 0 ) {
settings.off(':profile-toggled', this._profile_toggled, this);
settings.off(':profile-created', this._profile_created, this);
settings.off(':profile-changed', this._profile_changed, this);
settings.off(':profile-deleted', this._profile_deleted, this);
settings.off(':profiles-reordered', this._update_profiles, this);
context.off('context_changed', this._context_changed, this);
context.off('profiles_changed', this._context_changed, this);
}
}
};
}
};
return _c;
}

View file

@ -7,7 +7,7 @@
import Module from 'utilities/module';
import {deep_equals, has, debounce} from 'utilities/object';
import {CloudStorageProvider, LocalStorageProvider} from './providers';
import {IndexedDBProvider, LocalStorageProvider} from './providers';
import SettingsProfile from './profile';
import SettingsContext from './context';
import MigrationManager from './migration';
@ -218,11 +218,15 @@ export default class SettingsManager extends Module {
/**
* Evaluate the environment that FFZ is running in and then decide which
* provider should be used to retrieve and store settings.
*
* @returns {SettingsProvider} The provider to store everything.
*/
_createProvider() {
// If the loader has reported support for cloud settings...
if ( document.body.classList.contains('ffz-cloud-storage') )
return new CloudStorageProvider(this);
// Prefer IndexedDB if it's available because it's more persistent
// and can store more data. Plus, we don't have to faff around with
// JSON conversion all the time.
if ( IndexedDBProvider.supported() && localStorage.ffzIDB )
return this._idb = new IndexedDBProvider(this);
// Fallback
return new LocalStorageProvider(this);
@ -427,15 +431,18 @@ export default class SettingsManager extends Module {
* @param {number|SettingsProfile} id - The profile to delete
*/
deleteProfile(id) {
if ( typeof id === 'object' && id.id )
if ( typeof id === 'object' && id.id != null )
id = id.id;
const profile = this.__profile_ids[id];
if ( ! profile )
return;
if ( profile.id === 0 )
throw new Error('cannot delete default profile');
if ( this.__profiles.length === 1 )
throw new Error('cannot delete only profile');
/*if ( profile.id === 0 )
throw new Error('cannot delete default profile');*/
profile.off('toggled', this._onProfileToggled, this);
profile.clear();

View file

@ -7,6 +7,8 @@
import {EventEmitter} from 'utilities/events';
import {has} from 'utilities/object';
const DB_VERSION = 1;
// ============================================================================
// SettingsProvider
@ -48,6 +50,9 @@ export class SettingsProvider extends EventEmitter {
keys() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
entries() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
get size() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
get supportsBlobs() { return false; } // eslint-disable-line class-methods-use-this
}
@ -224,147 +229,133 @@ export class IndexedDBProvider extends SettingsProvider {
constructor(manager) {
super(manager);
this._cached = new Map;
this.ready = false;
this._ready_wait = null;
}
destroy() {
this.disable();
this._cached.clear();
}
disable() {
this.disabled = true;
}
awaitReady() {
if ( this.ready )
return Promise.resolve();
return new Promise((resolve, reject) => {
const waiters = this._ready_wait = this._ready_wait || [];
waiters.push([resolve, reject]);
})
}
}
export class CloudStorageProvider extends SettingsProvider {
constructor(manager) {
super(manager);
this._start_time = performance.now();
this._cached = new Map;
this.ready = false;
this._ready_wait = null;
this._boundHandleStorage = this.handleStorage.bind(this);
window.addEventListener('message', this._boundHandleStorage);
this._send('get_all');
}
destroy() {
this.disable();
this._cached.clear();
}
disable() {
this.disabled = true;
if ( this._boundHandleStorage ) {
window.removeEventListener('message', this._boundHandleStorage)
this._boundHandleStorage = null;
}
}
awaitReady() {
if ( this.ready )
return Promise.resolve();
return new Promise((resolve, reject) => {
const waiters = this._ready_wait = this._ready_wait || [];
waiters.push([resolve, reject]);
})
}
// ========================================================================
// Communication
// ========================================================================
handleStorage(event) {
if ( event.source !== window || ! event.data || ! event.data.ffz )
return;
const cmd = event.data.cmd,
data = event.data.data;
if ( cmd === 'all_values' ) {
const old_keys = new Set(this._cached.keys());
for(const key in data)
if ( has(data, key) ) {
const val = data[key];
old_keys.delete(key);
this._cached.set(key, val);
if ( this.ready )
this.emit('changed', key, val);
}
for(const key of old_keys) {
this._cached.delete(key);
if ( this.ready )
this.emit('changed', key, undefined, true);
}
this.ready = true;
if ( this._ready_wait ) {
for(const resolve of this._ready_wait)
resolve();
this._ready_wait = null;
}
} else if ( cmd === 'changed' ) {
this._cached.set(data.key, data.value);
this.emit('changed', data.key, data.value);
} else if ( cmd === 'deleted' ) {
this._cached.delete(data);
this.emit('changed', data, undefined, true);
if ( window.BroadcastChannel ) {
const bc = this._broadcaster = new BroadcastChannel('ffz-settings');
bc.addEventListener('message',
this._boundHandleMessage = this.handleMessage.bind(this));
} else {
this.manager.log.info('unknown storage event', event);
window.addEventListener('storage',
this._boundHandleStorage = this.handleStorage.bind(this));
}
this.loadSettings()
.then(() => this._resolveReady(true))
.catch(err => this._resolveReady(false, err));
}
_resolveReady(success, data) {
this.manager.log.info(`IDB ready in ${(performance.now() - this._start_time).toFixed(5)}ms`);
this.ready = success;
const waiters = this._ready_wait;
this._ready_wait = null;
if ( waiters )
for(const pair of waiters)
pair[success ? 0 : 1](data);
}
static supported() {
return window.indexedDB != null;
}
get supportsBlobs() { return true; } // eslint-disable-line class-methods-use-this
destroy() {
this.disable();
this._cached.clear();
}
disable() {
this.disabled = true;
if ( this.db ) {
this.db.close();
this.db = null;
}
if ( this._broadcaster ) {
this._broadcaster.removeEventListener('message', this._boundHandleMessage);
this._broadcaster.close();
this._boundHandleMessage = this._broadcaster = null;
}
}
_send(cmd, data) { // eslint-disable-line class-methods-use-this
window.postMessage({
ffz: true,
cmd,
data
}, location.origin);
broadcast(msg) {
if ( this._broadcaster )
this._broadcaster.postMessage(msg);
}
// ========================================================================
// Data Access
// ========================================================================
handleMessage(event) {
if ( this.disabled || ! event.isTrusted || ! event.data )
return;
this.manager.log.debug('storage broadcast event', event.data);
const {type, key} = event.data;
if ( type === 'set' ) {
const val = JSON.parse(localStorage.getItem(this.prefix + key));
this._cached.set(key, val);
this.emit('changed', key, val, false);
} else if ( type === 'delete' ) {
this._cached.delete(key);
this.emit('changed', key, undefined, true);
} else if ( type === 'clear' ) {
const old_keys = Array.from(this._cached.keys());
this._cached.clear();
for(const key of old_keys)
this.emit('changed', key, undefined, true);
}
}
awaitReady() {
if ( this.ready )
return Promise.resolve();
return new Promise((resolve, reject) => {
const waiters = this._ready_wait = this._ready_wait || [];
waiters.push([resolve, reject]);
})
}
// Synchronous Methods
get(key, default_value) {
return this._cached.has(key) ?
this._cached.get(key) :
default_value;
return this._cached.has(key) ? this._cached.get(key) : default_value;
}
set(key, value) {
if ( value === undefined ) {
if ( this.has(key) )
this.delete(key);
return;
}
this._cached.set(key, value);
this._send('set', {key, value});
this._set(key, value)
.catch(err => this.manager.log.error(`Error saving setting "${key}" to database`, err))
.then(() => this.broadcast({type: 'set', key}));
this.emit('set', key, value, false);
}
delete(key) {
this._cached.delete(key);
this._send('delete', key);
this._delete(key)
.catch(err => this.manager.log.error(`Error deleting setting "${key}" from database`, err))
.then(() => this.broadcast({type: 'delete', key}));
this.emit('set', key, undefined, true);
}
has(key) {
@ -376,8 +367,15 @@ export class CloudStorageProvider extends SettingsProvider {
}
clear() {
this._cached.clear();
this._send('clear');
const old_cache = this._cached;
this._cached = new Map;
for(const key of old_cache.keys())
this.emit('changed', key, undefined, true);
this._clear()
.catch(err => this.manager.log.error(`Error clearing database`, err))
.then(() => this.broadcast({type: 'clear'}));
}
entries() {
@ -387,4 +385,135 @@ export class CloudStorageProvider extends SettingsProvider {
get size() {
return this._cached.size;
}
// IDB Interaction
getDB() {
if ( this.db )
return Promise.resolve(this.db);
if ( this._listeners )
return new Promise((s,f) => this._listeners.push([s,f]));
return new Promise((s,f) => {
const listeners = this._listeners = [[s,f]],
done = (success, data) => {
if ( this._listeners === listeners ) {
this._listeners = null;
for(const pair of listeners)
pair[success ? 0 : 1](data);
}
}
const request = window.indexedDB.open('FFZ', DB_VERSION);
request.onerror = e => {
this.manager.log.error('Error opening database.', e);
done(false, e);
}
request.onupgradeneeded = e => {
this.manager.log.info(`Upgrading database from version ${e.oldVersion} to ${DB_VERSION}`);
const db = request.result;
db.createObjectStore('settings', {keyPath: 'k'});
db.createObjectStore('blobs');
}
request.onsuccess = () => {
this.manager.log.info(`Database opened. (After: ${(performance.now() - this._start_time).toFixed(5)}ms)`);
this.db = request.result;
done(true, this.db);
}
});
}
async loadSettings() {
const db = await this.getDB(),
trx = db.transaction(['settings'], 'readonly'),
store = trx.objectStore('settings');
return new Promise((s,f) => {
const request = store.getAll();
request.onsuccess = () => {
for(const entry of request.result)
this._cached.set(entry.k, entry.v);
s();
}
request.onerror = err => {
this.manager.log.error('Error reading settings from database.', err);
f();
}
});
/*cursor = store.openCursor();
return new Promise((s,f) => {
cursor.onsuccess = e => {
const entry = e.target.result;
if ( entry ) {
this._cached.set(entry.key, entry.value);
entry.continue();
} else {
// We're done~!
s();
}
};
cursor.onerror = e => {
this.manager.log.error('Error reading settings from database.', e);
f(e);
}
});*/
}
async _set(key, value) {
const db = await this.getDB(),
trx = db.transaction(['settings'], 'readwrite'),
store = trx.objectStore('settings');
return new Promise((s,f) => {
store.onerror = f;
store.onsuccess = s;
store.put({k: key, v: value});
});
}
async _delete(key) {
const db = await this.getDB(),
trx = db.transaction(['settings'], 'readwrite'),
store = trx.objectStore('settings');
return new Promise((s,f) => {
store.onerror = f;
store.onsuccess = s;
store.delete(key);
});
}
async _clear() {
const db = await this.getDB(),
trx = db.transaction(['settings'], 'readwrite'),
store = trx.objectStore('settings');
return new Promise((s,f) => {
store.onerror = f;
store.onsuccess = s;
store.clear();
});
}
}

View file

@ -10,7 +10,7 @@ export default class BaseSite extends Module {
super(...args);
this._id = `_ffz$${last_site++}`;
this.inject('settings');
//this.inject('settings');
this.log.info(`Using: ${this.constructor.name}`);
}

View file

@ -54,6 +54,8 @@ export default class Twilight extends BaseSite {
}
onEnable() {
this.settings = this.resolve('settings');
const thing = this.fine.searchNode(null, n => n?.pendingProps?.store?.getState),
store = this.store = thing?.pendingProps?.store;

View file

@ -270,8 +270,14 @@ export default class Channel extends Module {
updateRoot(el) {
const root = this.fine.getReactInstance(el),
channel = root?.return?.memoizedState?.next?.memoizedState?.current?.previousData?.result?.data?.user;
const root = this.fine.getReactInstance(el);
let channel = null, state = root?.return?.memoizedState, i = 0;
while(state != null && channel == null && i < 50 ) {
state = state?.next;
channel = state?.memoizedState?.current?.previousData?.result?.data?.user;
i++;
}
if ( channel && channel.id ) {
this.updateChannelColor(channel.primaryColorHex);

View file

@ -143,12 +143,14 @@ export default class ChatHook extends Module {
this.inject('settings');
this.inject('i18n');
this.inject('experiments');
this.inject('site');
this.inject('site.router');
this.inject('site.fine');
this.inject('site.web_munch');
this.inject('site.css_tweaks');
this.inject('site.subpump');
this.inject('chat');
@ -1076,6 +1078,38 @@ export default class ChatHook extends Module {
for(const inst of instances)
this.containerMounted(inst);
});
this.subpump.on(':pubsub-message', event => {
if ( event.prefix !== 'community-points-channel-v1' || ! this.experiments.getAssignment('all_points') )
return;
const service = this.ChatService.first,
message = event.message,
data = message?.data?.redemption;
if ( ! message || ! service || message.type !== 'reward-redeemed' || service.props.channelID != data?.channel_id )
return;
const reward = data.reward?.id && get(data.reward.id, service.props.rewardMap);
if ( ! reward )
return;
const msg = {
id: data.id,
type: this.chat_types.Message,
ffz_type: 'points',
ffz_reward: reward,
messageParts: [],
user: {
id: data.user.id,
login: data.user.login,
displayName: data.user.display_name
},
timestamp: new Date(message.data.timestamp || data.redeemed_at).getTime()
};
service.postMessageToCurrentChannel({}, msg);
event.preventDefault();
});
}

View file

@ -385,7 +385,7 @@ other {# messages were deleted by a moderator.}
onClick: this.ffz_user_click_handler
}, e('span', {
className: 'tw-c-text-base tw-strong'
}, user.userDisplayName)),
}, user.displayName)),
count: msg.sub_count,
tier: SUB_TIERS[msg.sub_plan] || 1,
channel: msg.roomLogin
@ -477,7 +477,7 @@ other {# messages were deleted by a moderator.}
onClick: this.ffz_user_click_handler
}, e('span', {
className: 'tw-c-text-base tw-strong'
}, user.userDisplayName)),
}, user.displayName)),
plan: plan.plan === 'custom' ? '' :
t.i18n.t('chat.sub.gift-plan', 'Tier {tier}', {tier}),
recipient: e('span', {
@ -539,7 +539,7 @@ other {# messages were deleted by a moderator.}
onClick: this.ffz_user_click_handler
}, e('span', {
className: 'tw-c-text-base tw-strong'
}, user.userDisplayName)),
}, user.displayName)),
plan: plan.prime ?
t.i18n.t('chat.sub.twitch-prime', 'with Twitch Prime') :
t.i18n.t('chat.sub.plan', 'at Tier {tier}', {tier})
@ -598,7 +598,7 @@ other {# messages were deleted by a moderator.}
onClick: this.ffz_user_click_handler
}, e('span', {
className: 'tw-c-text-base tw-strong'
}, user.userDisplayName))
}, user.displayName))
})
]);
@ -640,7 +640,7 @@ other {# messages were deleted by a moderator.}
onClick: this.ffz_user_click_handler
}, e('span', {
className: 'tw-c-text-base tw-strong'
}, user.userDisplayName))
}, user.displayName))
})
]),
out && e('div', {
@ -793,7 +793,7 @@ other {# messages were deleted by a moderator.}
const msg = inst.props.message;
if ( msg ) {
msg.ffz_tokens = null;
msg.mentioned = msg.mention_color = null;
msg.highlights = msg.mentioned = msg.mention_color = null;
}
}
@ -801,7 +801,7 @@ other {# messages were deleted by a moderator.}
const msg = inst.props.message;
if ( msg ) {
msg.ffz_tokens = null;
msg.mentioned = msg.mention_color = null;
msg.highlights = msg.mentioned = msg.mention_color = null;
}
}

View file

@ -12,6 +12,8 @@ button[data-a-target="emote-picker-button"] {
font-weight: normal;
speak: none;
margin-top: -.3rem;
font-variant: normal;
text-transform: none;

View file

@ -1,3 +1,8 @@
// Fix: Unable to move emote/user cards out of chat
.channel-root__right-column > div.tw-overflow-hidden {
overflow: unset !important;
}
.chat-line__message--emote {
vertical-align: middle;
margin: -.5rem 0;

View file

@ -97,7 +97,7 @@ export class Logger {
message.unshift('%cFFZ:%c', 'color:#755000; font-weight:bold', '');
if ( level === Logger.DEBUG )
console.info(...message);
console.debug(...message);
else if ( level === Logger.INFO )
console.info(...message);

View file

@ -70,13 +70,13 @@
.ffz-i-zreknarf:before {
vertical-align: middle;
margin-bottom: -0.1em;
margin-top: 0;
}
.ffz-i-discord:before,
.ffz-i-twitter:before {
vertical-align: middle;
margin-bottom: -0.3em;
margin-bottom: -0.1em;
}
}