1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
* Changed: Replace the old Twitter widget on the FFZ Control Center's Home page with a custom Bluesky widget.
* Fixed: Settings profile rules for the current channel and current category not functioning correctly.

* Developer Changed: We TypeScript (a work-in-progress conversion)
* Developer Fixed: The GraphQL inspector not properly displaying data.
This commit is contained in:
SirStendec 2023-12-14 17:52:35 -05:00
parent 31e7ce4ac5
commit 18491b0873
25 changed files with 846 additions and 208 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "frankerfacez", "name": "frankerfacez",
"author": "Dan Salvato LLC", "author": "Dan Salvato LLC",
"version": "4.60.1", "version": "4.61.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.", "description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",

View file

@ -46,6 +46,12 @@ type AddonManagerEvents = {
}; };
type FullAddonInfo = AddonInfo & {
_search?: string | null;
src: string;
};
// ============================================================================ // ============================================================================
// AddonManager // AddonManager
// ============================================================================ // ============================================================================
@ -62,7 +68,7 @@ export default class AddonManager extends Module<'addons'> {
reload_required: boolean; reload_required: boolean;
target: string; target: string;
addons: Record<string, AddonInfo | string[]>; addons: Record<string, FullAddonInfo | string[]>;
enabled_addons: string[]; enabled_addons: string[];
private _loader?: Promise<void>; private _loader?: Promise<void>;
@ -239,7 +245,9 @@ export default class AddonManager extends Module<'addons'> {
this.emit(':data-loaded'); this.emit(':data-loaded');
} }
addAddon(addon: AddonInfo, is_dev: boolean = false) { addAddon(input: AddonInfo, is_dev: boolean = false) {
let addon = input as FullAddonInfo;
const old = this.addons[addon.id]; const old = this.addons[addon.id];
this.addons[addon.id] = addon; this.addons[addon.id] = addon;
@ -269,7 +277,7 @@ export default class AddonManager extends Module<'addons'> {
this.addons[id] = [addon.id]; this.addons[id] = [addon.id];
} }
if ( ! old ) if ( ! old || Array.isArray(old) )
this.settings.addUI(`addon-changelog.${addon.id}`, { this.settings.addUI(`addon-changelog.${addon.id}`, {
path: `Add-Ons > Changelog > ${addon.name}`, path: `Add-Ons > Changelog > ${addon.name}`,
component: 'changelog', component: 'changelog',
@ -284,6 +292,9 @@ export default class AddonManager extends Module<'addons'> {
rebuildAddonSearch() { rebuildAddonSearch() {
for(const addon of Object.values(this.addons)) { for(const addon of Object.values(this.addons)) {
if ( Array.isArray(addon) )
continue;
const terms = new Set([ const terms = new Set([
addon._search, addon._search,
addon.name, addon.name,
@ -302,11 +313,15 @@ export default class AddonManager extends Module<'addons'> {
if ( addon.author_i18n ) if ( addon.author_i18n )
terms.add(this.i18n.t(addon.author_i18n, addon.author)); terms.add(this.i18n.t(addon.author_i18n, addon.author));
if ( addon.maintainer_i18n )
terms.add(this.i18n.t(addon.maintainer_i18n, addon.maintainer));
if ( addon.description_i18n ) if ( addon.description_i18n )
terms.add(this.i18n.t(addon.description_i18n, addon.description)); terms.add(this.i18n.t(addon.description_i18n, addon.description));
} }
addon.search_terms = [...terms].map(term => term ? term.toLocaleLowerCase() : '').join('\n'); addon.search_terms = [...terms]
.map(term => term ? term.toLocaleLowerCase() : '').join('\n');
} }
} }

View file

@ -13,6 +13,7 @@ import Cookie from 'js-cookie';
import SHA1 from 'crypto-js/sha1'; import SHA1 from 'crypto-js/sha1';
import type SettingsManager from './settings'; import type SettingsManager from './settings';
import type { ExperimentTypeMap } from 'utilities/types';
declare module 'utilities/types' { declare module 'utilities/types' {
interface ModuleMap { interface ModuleMap {
@ -22,7 +23,16 @@ declare module 'utilities/types' {
experiments: ExperimentEvents; experiments: ExperimentEvents;
} }
interface ProviderTypeMap { interface ProviderTypeMap {
'experiment-overrides': Record<string, unknown> 'experiment-overrides': {
[K in keyof ExperimentTypeMap]?: ExperimentTypeMap[K];
}
}
interface PubSubCommands {
reload_experiments: [];
update_experiment: {
key: keyof ExperimentTypeMap,
data: FFZExperimentData | ExperimentGroup[]
};
} }
} }
@ -90,8 +100,19 @@ export type OverrideCookie = {
type ExperimentEvents = { type ExperimentEvents = {
':changed': [key: string, new_value: any, old_value: any]; ':changed': [key: string, new_value: any, old_value: any];
':twitch-changed': [key: string, new_value: string | null, old_value: string | null]; ':twitch-changed': [key: string, new_value: string | null, old_value: string | null];
[key: `:changed:${string}`]: [new_value: any, old_value: any];
[key: `:twitch-changed:${string}`]: [new_value: string | null, old_value: string | null]; [key: `:twitch-changed:${string}`]: [new_value: string | null, old_value: string | null];
} & {
[K in keyof ExperimentTypeMap as `:changed:${K}`]: [new_value: ExperimentTypeMap[K], old_value: ExperimentTypeMap[K] | null];
};
type ExperimentLogEntry = {
key: string;
name: string;
value: any;
override: boolean;
rarity: number;
type?: string;
} }
@ -107,7 +128,7 @@ export function isFFZExperiment(exp: ExperimentData): exp is FFZExperimentData {
return 'description' in exp; return 'description' in exp;
} }
function sortExperimentLog(a,b) { function sortExperimentLog(a: ExperimentLogEntry, b: ExperimentLogEntry) {
if ( a.rarity < b.rarity ) if ( a.rarity < b.rarity )
return -1; return -1;
else if ( a.rarity > b.rarity ) else if ( a.rarity > b.rarity )
@ -133,9 +154,11 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
// State // State
unique_id?: string; unique_id?: string;
experiments: Record<string, FFZExperimentData>; experiments: Partial<{
[K in keyof ExperimentTypeMap]: FFZExperimentData;
}>;
private cache: Map<string, unknown>; private cache: Map<keyof ExperimentTypeMap, unknown>;
// Helpers // Helpers
@ -155,19 +178,20 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
no_filter: true, no_filter: true,
getExtraTerms: () => { getExtraTerms: () => {
const values = []; const values: string[] = [];
for(const exps of [this.experiments, this.getTwitchExperiments()]) { for(const [key, val] of Object.entries(this.experiments)) {
if ( ! exps ) values.push(key);
continue; if ( val.name )
values.push(val.name);
if ( val.description )
values.push(val.description);
}
for(const [key, val] of Object.entries(exps)) { for(const [key, val] of Object.entries(this.getTwitchExperiments())) {
values.push(key); values.push(key);
if ( val.name ) if ( val.name )
values.push(val.name); values.push(val.name);
if ( val.description )
values.push(val.description);
}
} }
return values; return values;
@ -178,7 +202,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
unique_id: () => this.unique_id, unique_id: () => this.unique_id,
ffz_data: () => deep_copy(this.experiments) ?? {}, ffz_data: () => deep_copy(this.experiments),
twitch_data: () => deep_copy(this.getTwitchExperiments()), twitch_data: () => deep_copy(this.getTwitchExperiments()),
usingTwitchExperiment: (key: string) => this.usingTwitchExperiment(key), usingTwitchExperiment: (key: string) => this.usingTwitchExperiment(key),
@ -188,10 +212,10 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
setTwitchOverride: (key: string, val: string) => this.setTwitchOverride(key, val), setTwitchOverride: (key: string, val: string) => this.setTwitchOverride(key, val),
deleteTwitchOverride: (key: string) => this.deleteTwitchOverride(key), deleteTwitchOverride: (key: string) => this.deleteTwitchOverride(key),
getAssignment: (key: string) => this.getAssignment(key), getAssignment: <K extends keyof ExperimentTypeMap>(key: K) => this.getAssignment(key),
hasOverride: (key: string) => this.hasOverride(key), hasOverride: (key: keyof ExperimentTypeMap) => this.hasOverride(key),
setOverride: (key: string, val: any) => this.setOverride(key, val), setOverride: <K extends keyof ExperimentTypeMap>(key: K, val: ExperimentTypeMap[K]) => this.setOverride(key, val),
deleteOverride: (key: string) => this.deleteOverride(key), deleteOverride: (key: keyof ExperimentTypeMap) => this.deleteOverride(key),
on: (...args: Parameters<typeof this.on>) => this.on(...args), on: (...args: Parameters<typeof this.on>) => this.on(...args),
off: (...args: Parameters<typeof this.off>) => this.off(...args) off: (...args: Parameters<typeof this.off>) => this.off(...args)
@ -226,7 +250,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
async loadExperiments() { async loadExperiments() {
let data: Record<string, FFZExperimentData> | null; let data: Record<keyof ExperimentTypeMap, FFZExperimentData> | null;
try { try {
data = await fetchJSON(DEBUG data = await fetchJSON(DEBUG
@ -254,7 +278,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
if ( old_val !== new_val ) { if ( old_val !== new_val ) {
changed++; changed++;
this.emit(':changed', key, new_val, old_val); this.emit(':changed', key, new_val, old_val);
this.emit(`:changed:${key}`, new_val, old_val); this.emit(`:changed:${key as keyof ExperimentTypeMap}`, new_val as any, old_val as any);
} }
} }
@ -265,18 +289,21 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
/** @internal */ /** @internal */
onEnable() { onEnable() {
this.on('pubsub:command:reload_experiments', this.loadExperiments, this); this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
this.on('pubsub:command:update_experiment', this.updateExperiment, this); this.on('pubsub:command:update_experiment', data => {
this.updateExperiment(data.key, data.data);
}, this);
} }
updateExperiment(key: string, data: FFZExperimentData | ExperimentGroup[]) { updateExperiment(key: keyof ExperimentTypeMap, data: FFZExperimentData | ExperimentGroup[]) {
this.log.info(`Received updated data for experiment "${key}" via PubSub.`, data); this.log.info(`Received updated data for experiment "${key}" via PubSub.`, data);
if ( Array.isArray(data) ) { if ( Array.isArray(data) ) {
if ( ! this.experiments[key] ) const existing = this.experiments[key];
if ( ! existing )
return; return;
this.experiments[key].groups = data; existing.groups = data;
} else if ( data?.groups ) } else if ( data?.groups )
this.experiments[key] = data; this.experiments[key] = data;
@ -291,8 +318,8 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
'' ''
]; ];
const ffz_assignments = []; const ffz_assignments: ExperimentLogEntry[] = [];
for(const [key, value] of Object.entries(this.experiments)) { for(const [key, value] of Object.entries(this.experiments) as [keyof ExperimentTypeMap, FFZExperimentData][]) {
const assignment = this.getAssignment(key), const assignment = this.getAssignment(key),
override = this.hasOverride(key); override = this.hasOverride(key);
@ -322,7 +349,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
for(const entry of ffz_assignments) for(const entry of ffz_assignments)
out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`); out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`);
const twitch_assignments = [], const twitch_assignments: ExperimentLogEntry[] = [],
channel = this.settings.get('context.channel'); channel = this.settings.get('context.channel');
for(const [key, value] of Object.entries(this.getTwitchExperiments())) { for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
@ -556,7 +583,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
return this.getTwitchAssignment(key, channel); return this.getTwitchAssignment(key, channel);
} }
_rebuildTwitchKey( private _rebuildTwitchKey(
key: string, key: string,
is_set: boolean, is_set: boolean,
new_val: string | null new_val: string | null
@ -578,7 +605,9 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
// FFZ Experiments // FFZ Experiments
setOverride(key: string, value: unknown = null) { setOverride<
K extends keyof ExperimentTypeMap
>(key: K, value: ExperimentTypeMap[K]) {
const overrides = this.settings.provider.get('experiment-overrides', {}); const overrides = this.settings.provider.get('experiment-overrides', {});
overrides[key] = value; overrides[key] = value;
@ -587,7 +616,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
this._rebuildKey(key); this._rebuildKey(key);
} }
deleteOverride(key: string) { deleteOverride(key: keyof ExperimentTypeMap) {
const overrides = this.settings.provider.get('experiment-overrides'); const overrides = this.settings.provider.get('experiment-overrides');
if ( ! overrides || ! has(overrides, key) ) if ( ! overrides || ! has(overrides, key) )
return; return;
@ -601,33 +630,37 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
this._rebuildKey(key); this._rebuildKey(key);
} }
hasOverride(key: string) { hasOverride(key: keyof ExperimentTypeMap) {
const overrides = this.settings.provider.get('experiment-overrides'); const overrides = this.settings.provider.get('experiment-overrides');
return overrides ? has(overrides, key): false; return overrides ? has(overrides, key): false;
} }
get: <T>(key: string) => T | null; get: <K extends keyof ExperimentTypeMap>(
key: K
) => ExperimentTypeMap[K];
getAssignment<T>(key: string): T | null { getAssignment<K extends keyof ExperimentTypeMap>(
key: K
): ExperimentTypeMap[K] {
if ( this.cache.has(key) ) if ( this.cache.has(key) )
return this.cache.get(key) as T; return this.cache.get(key) as ExperimentTypeMap[K];
const experiment = this.experiments[key]; const experiment = this.experiments[key];
if ( ! experiment ) { if ( ! experiment ) {
this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`); this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`);
return null; return null as ExperimentTypeMap[K];
} }
const overrides = this.settings.provider.get('experiment-overrides'), const overrides = this.settings.provider.get('experiment-overrides'),
out = overrides && has(overrides, key) ? out = overrides && has(overrides, key) ?
overrides[key] as T : overrides[key] :
ExperimentManager.selectGroup<T>(key, experiment, this.unique_id ?? ''); ExperimentManager.selectGroup<ExperimentTypeMap[K]>(key, experiment, this.unique_id ?? '');
this.cache.set(key, out); this.cache.set(key, out);
return out; return out as ExperimentTypeMap[K];
} }
_rebuildKey(key: string) { private _rebuildKey(key: keyof ExperimentTypeMap) {
if ( ! this.cache.has(key) ) if ( ! this.cache.has(key) )
return; return;

View file

@ -614,7 +614,7 @@ export default class Actions extends Module {
}, },
onMove: (target, tip, event) => { onMove: (target, tip, event) => {
this.emit('tooltips:mousemove', target, tip, event) this.emit('tooltips:hover', target, tip, event)
}, },
onLeave: (target, tip, event) => { onLeave: (target, tip, event) => {
@ -1276,4 +1276,4 @@ export default class Actions extends Module {
sendMessage(room, message) { sendMessage(room, message) {
return this.resolve('site.chat').sendMessage(room, message); return this.resolve('site.chat').sendMessage(room, message);
} }
} }

View file

@ -4,14 +4,43 @@
// Name and Color Overrides // Name and Color Overrides
// ============================================================================ // ============================================================================
import Module from 'utilities/module'; import Module, { GenericModule } from 'utilities/module';
import { createElement, ClickOutside } from 'utilities/dom'; import { createElement, ClickOutside } from 'utilities/dom';
import Tooltip from 'utilities/tooltip'; import Tooltip from 'utilities/tooltip';
import type SettingsManager from 'root/src/settings';
export default class Overrides extends Module { declare module 'utilities/types' {
constructor(...args) { interface ModuleMap {
super(...args); 'chat.overrides': Overrides;
}
interface ModuleEventMap {
'chat.overrides': OverrideEvents;
}
interface ProviderTypeMap {
'overrides.colors': Record<string, string | undefined>;
'overrides.names': Record<string, string | undefined>;
}
}
export type OverrideEvents = {
':changed': [id: string, type: 'name' | 'color', value: string | undefined];
}
export default class Overrides extends Module<'chat.overrides'> {
// Dependencies
settings: SettingsManager = null as any;
// State and Caching
color_cache: Record<string, string | undefined> | null;
name_cache: Record<string, string | undefined> | null;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.inject('settings'); this.inject('settings');
@ -35,12 +64,15 @@ export default class Overrides extends Module {
});*/ });*/
} }
/** @internal */
onEnable() { onEnable() {
this.settings.provider.on('changed', this.onProviderChange, this); this.settings.provider.on('changed', this.onProviderChange, this);
} }
renderUserEditor(user, target) { renderUserEditor(user: any, target: HTMLElement) {
let outside, popup, ve; let outside: ClickOutside | null,
popup: Tooltip | null,
ve: any;
const destroy = () => { const destroy = () => {
const o = outside, p = popup, v = ve; const o = outside, p = popup, v = ve;
@ -56,7 +88,10 @@ export default class Overrides extends Module {
v.$destroy(); v.$destroy();
} }
const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body; const parent =
document.fullscreenElement as HTMLElement
?? document.body.querySelector<HTMLElement>('#root>div')
?? document.body;
popup = new Tooltip(parent, [], { popup = new Tooltip(parent, [], {
logger: this.log, logger: this.log,
@ -88,6 +123,9 @@ export default class Overrides extends Module {
const vue = this.resolve('vue'), const vue = this.resolve('vue'),
_editor = import(/* webpackChunkName: "overrides" */ './override-editor.vue'); _editor = import(/* webpackChunkName: "overrides" */ './override-editor.vue');
if ( ! vue )
throw new Error('unable to load vue');
const [, editor] = await Promise.all([vue.enable(), _editor]); const [, editor] = await Promise.all([vue.enable(), _editor]);
vue.component('override-editor', editor.default); vue.component('override-editor', editor.default);
@ -118,12 +156,13 @@ export default class Overrides extends Module {
onShow: async (t, tip) => { onShow: async (t, tip) => {
await tip.waitForDom(); await tip.waitForDom();
requestAnimationFrame(() => { requestAnimationFrame(() => {
outside = new ClickOutside(tip.outer, destroy) if ( tip.outer )
outside = new ClickOutside(tip.outer, destroy)
}); });
}, },
onMove: (target, tip, event) => { onMove: (target, tip, event) => {
this.emit('tooltips:mousemove', target, tip, event) this.emit('tooltips:hover', target, tip, event)
}, },
onLeave: (target, tip, event) => { onLeave: (target, tip, event) => {
@ -137,30 +176,25 @@ export default class Overrides extends Module {
} }
onProviderChange(key) { onProviderChange(key: string) {
if ( key === 'overrides.colors' ) if ( key === 'overrides.colors' && this.color_cache )
this.loadColors(); this.loadColors();
else if ( key === 'overrides.names' ) else if ( key === 'overrides.names' && this.name_cache )
this.loadNames(); this.loadNames();
} }
get colors() { get colors() {
if ( ! this.color_cache ) return this.color_cache ?? this.loadColors();
this.loadColors();
return this.color_cache;
} }
get names() { get names() {
if ( ! this.name_cache ) return this.name_cache ?? this.loadNames();
this.loadNames();
return this.name_cache;
} }
loadColors() { loadColors() {
let old_keys, let old_keys: Set<string>,
loaded = true; loaded = true;
if ( ! this.color_cache ) { if ( ! this.color_cache ) {
loaded = false; loaded = false;
this.color_cache = {}; this.color_cache = {};
@ -168,24 +202,28 @@ export default class Overrides extends Module {
} else } else
old_keys = new Set(Object.keys(this.color_cache)); old_keys = new Set(Object.keys(this.color_cache));
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.colors', {}))) { const entries = this.settings.provider.get('overrides.colors');
old_keys.delete(key); if ( entries )
if ( this.color_cache[key] !== val ) { for(const [key, val] of Object.entries(entries)) {
this.color_cache[key] = val; old_keys.delete(key);
if ( loaded ) if ( this.color_cache[key] !== val ) {
this.emit(':changed', key, 'color', val); this.color_cache[key] = val;
if ( loaded )
this.emit(':changed', key, 'color', val);
}
} }
}
for(const key of old_keys) { for(const key of old_keys) {
this.color_cache[key] = undefined; this.color_cache[key] = undefined;
if ( loaded ) if ( loaded )
this.emit(':changed', key, 'color', undefined); this.emit(':changed', key, 'color', undefined);
} }
return this.color_cache;
} }
loadNames() { loadNames() {
let old_keys, let old_keys: Set<string>,
loaded = true; loaded = true;
if ( ! this.name_cache ) { if ( ! this.name_cache ) {
loaded = false; loaded = false;
@ -194,37 +232,35 @@ export default class Overrides extends Module {
} else } else
old_keys = new Set(Object.keys(this.name_cache)); old_keys = new Set(Object.keys(this.name_cache));
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.names', {}))) { const entries = this.settings.provider.get('overrides.names');
old_keys.delete(key); if ( entries )
if ( this.name_cache[key] !== val ) { for(const [key, val] of Object.entries(entries)) {
this.name_cache[key] = val; old_keys.delete(key);
if ( loaded ) if ( this.name_cache[key] !== val ) {
this.emit(':changed', key, 'name', val); this.name_cache[key] = val;
if ( loaded )
this.emit(':changed', key, 'name', val);
}
} }
}
for(const key of old_keys) { for(const key of old_keys) {
this.name_cache[key] = undefined; this.name_cache[key] = undefined;
if ( loaded ) if ( loaded )
this.emit(':changed', key, 'name', undefined); this.emit(':changed', key, 'name', undefined);
} }
return this.name_cache;
} }
getColor(id) { getColor(id: string): string | null {
if ( this.colors[id] != null ) return this.colors[id] ?? null;
return this.colors[id];
return null;
} }
getName(id) { getName(id: string) {
if ( this.names[id] != null ) return this.names[id] ?? null;
return this.names[id];
return null;
} }
setColor(id, color) { setColor(id: string, color?: string) {
if ( this.colors[id] !== color ) { if ( this.colors[id] !== color ) {
this.colors[id] = color; this.colors[id] = color;
this.settings.provider.set('overrides.colors', this.colors); this.settings.provider.set('overrides.colors', this.colors);
@ -232,7 +268,7 @@ export default class Overrides extends Module {
} }
} }
setName(id, name) { setName(id: string, name?: string) {
if ( this.names[id] !== name ) { if ( this.names[id] !== name ) {
this.names[id] = name; this.names[id] = name;
this.settings.provider.set('overrides.names', this.names); this.settings.provider.set('overrides.names', this.names);
@ -240,11 +276,11 @@ export default class Overrides extends Module {
} }
} }
deleteColor(id) { deleteColor(id: string) {
this.setColor(id, undefined); this.setColor(id, undefined);
} }
deleteName(id) { deleteName(id: string) {
this.setName(id, undefined); this.setName(id, undefined);
} }
} }

View file

@ -0,0 +1,8 @@
// ============================================================================
// Badges
// ============================================================================
export type BadgeAssignment = {
id: string;
};

View file

@ -5,20 +5,39 @@
// ============================================================================ // ============================================================================
import {SourcedSet} from 'utilities/object'; import {SourcedSet} from 'utilities/object';
import type Chat from '.';
import type Room from './room';
import type { BadgeAssignment } from './types';
export default class User { export default class User {
constructor(manager, room, id, login) {
// Parent
manager: Chat;
room: Room | null;
// State
destroyed: boolean = false;
_id: string | null;
_login: string | null = null;
// Storage
emote_sets: SourcedSet<string> | null;
badges: SourcedSet<BadgeAssignment> | null;
constructor(manager: Chat, room: Room | null, id: string | null, login: string | null) {
this.manager = manager; this.manager = manager;
this.room = room; this.room = room;
this.emote_sets = null; //new SourcedSet; this.emote_sets = null;
this.badges = null; // new SourcedSet; this.badges = null;
this._id = id; this._id = id;
this.login = login; this.login = login;
if ( id ) if ( id )
(room || manager).user_ids[id] = this; (room ?? manager).user_ids[id] = this;
} }
destroy() { destroy() {
@ -31,6 +50,7 @@ export default class User {
this.emote_sets = null; this.emote_sets = null;
} }
// Badges are not referenced, so we can just dump them all.
if ( this.badges ) if ( this.badges )
this.badges = null; this.badges = null;
@ -45,26 +65,24 @@ export default class User {
} }
} }
merge(other) { merge(other: User) {
if ( ! this.login && other.login ) if ( ! this.login && other.login )
this.login = other.login; this.login = other.login;
if ( other.emote_sets && other.emote_sets._sources ) { if ( other.emote_sets )
for(const [provider, sets] of other.emote_sets._sources.entries()) { for(const [provider, sets] of other.emote_sets.iterateSources()) {
for(const set_id of sets) for(const set_id of sets)
this.addSet(provider, set_id); this.addSet(provider, set_id);
} }
}
if ( other.badges && other.badges._sources ) { if ( other.badges )
for(const [provider, badges] of other.badges._sources.entries()) { for(const [provider, badges] of other.badges.iterateSources()) {
for(const badge of badges) for(const badge of badges)
this.addBadge(provider, badge.id, badge); this.addBadge(provider, badge.id, badge);
} }
}
} }
_unloadAddon(addon_id) { _unloadAddon(addon_id: string) {
// TODO: This // TODO: This
return 0; return 0;
} }
@ -107,9 +125,9 @@ export default class User {
// Add Badges // Add Badges
// ======================================================================== // ========================================================================
addBadge(provider, badge_id, data) { addBadge(provider: string, badge_id: string, data?: BadgeAssignment) {
if ( this.destroyed ) if ( this.destroyed )
return; return false;
if ( typeof badge_id === 'number' ) if ( typeof badge_id === 'number' )
badge_id = `${badge_id}`; badge_id = `${badge_id}`;
@ -122,8 +140,9 @@ export default class User {
if ( ! this.badges ) if ( ! this.badges )
this.badges = new SourcedSet; this.badges = new SourcedSet;
if ( this.badges.has(provider) ) const existing = this.badges.get(provider);
for(const old_b of this.badges.get(provider)) if ( existing )
for(const old_b of existing)
if ( old_b.id == badge_id ) { if ( old_b.id == badge_id ) {
Object.assign(old_b, data); Object.assign(old_b, data);
return false; return false;
@ -135,31 +154,35 @@ export default class User {
} }
getBadge(badge_id) { getBadge(badge_id: string) {
if ( ! this.badges ) if ( this.badges )
return null; for(const badge of this.badges._cache)
if ( badge.id == badge_id )
return badge;
for(const badge of this.badges._cache) return null;
if ( badge.id == badge_id )
return badge;
} }
removeBadge(provider, badge_id) { removeBadge(provider: string, badge_id: string) {
if ( ! this.badges || ! this.badges.has(provider) ) if ( ! this.badges )
return false; return false;
for(const old_b of this.badges.get(provider)) const existing = this.badges.get(provider);
if ( old_b.id == badge_id ) { if ( existing )
this.badges.remove(provider, old_b); for(const old_b of existing)
//this.manager.badges.unrefBadge(badge_id); if ( old_b.id == badge_id ) {
return true; this.badges.remove(provider, old_b);
} //this.manager.badges.unrefBadge(badge_id);
return true;
}
return false;
} }
removeAllBadges(provider) { removeAllBadges(provider: string) {
if ( this.destroyed || ! this.badges ) if ( ! this.badges )
return false; return false;
if ( ! this.badges.has(provider) ) if ( ! this.badges.has(provider) )
@ -175,7 +198,7 @@ export default class User {
// Emote Sets // Emote Sets
// ======================================================================== // ========================================================================
addSet(provider, set_id, data) { addSet(provider: string, set_id: string, data?: unknown) {
if ( this.destroyed ) if ( this.destroyed )
return; return;
@ -203,8 +226,8 @@ export default class User {
return added; return added;
} }
removeAllSets(provider) { removeAllSets(provider: string) {
if ( this.destroyed || ! this.emote_sets ) if ( ! this.emote_sets )
return false; return false;
const sets = this.emote_sets.get(provider); const sets = this.emote_sets.get(provider);
@ -217,8 +240,8 @@ export default class User {
return true; return true;
} }
removeSet(provider, set_id) { removeSet(provider: string, set_id: string) {
if ( this.destroyed || ! this.emote_sets ) if ( ! this.emote_sets )
return; return;
if ( typeof set_id === 'number' ) if ( typeof set_id === 'number' )
@ -235,4 +258,4 @@ export default class User {
return false; return false;
} }
} }

View file

@ -119,8 +119,8 @@ export default {
result: null result: null
}); });
this.queryMap[name].variables = deep_copy(query.observableQuery?.variables); this.queryMap[name].variables = deep_copy(query.observableQuery?.last?.variables ?? query.observableQuery?.variables);
this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? null); this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? query.observableQuery?.last?.result?.data ?? null);
} }
if ( ! this.current ) if ( ! this.current )

View file

@ -190,7 +190,7 @@
</div> </div>
<rich-feed <rich-feed
url="https://bsky-feed.special.frankerfacez.com/user::stendec.dev" url="https://bsky-feed.special.frankerfacez.com/user::frankerfacez.com"
:context="context" :context="context"
/> />

View file

@ -2,10 +2,47 @@
<div class="ffz--menu-page"> <div class="ffz--menu-page">
<header class="tw-mg-b-1"> <header class="tw-mg-b-1">
<span v-for="i in breadcrumbs" :key="i.full_key"> <span v-for="i in breadcrumbs" :key="i.full_key">
<a v-if="i !== item" href="#" @click="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a> <a v-if="i !== item" href="#" @click.prevent="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a>
<strong v-if="i === item">{{ t(i.i18n_key, i.title) }}</strong> <strong v-if="i === item">{{ t(i.i18n_key, i.title) }}</strong>
<template v-if="i !== item">&raquo; </template> <template v-if="i !== item">&raquo; </template>
</span> </span>
<span v-if="item.header_links" class="ffz--menu-page__header-links">
<span class="tw-mg-x-05"></span>
<template v-for="i in item.header_links">
<a
v-if="i.href && i.href.startsWith('~')"
class="tw-mg-r-05"
href="#"
@click.prevent="$emit('navigate', i.href.slice(1))"
>{{
t(i.i18n_key, i.title)
}}</a>
<react-link
v-else-if="i.href"
class="tw-mg-r-05"
:href="i.href"
:state="i.state"
>{{
t(i.i18n_key, i.title)
}}</react-link>
<a
v-else-if="i.navigate"
class="tw-mg-r-05"
href="#"
@click.prevent="navigate(...i.navigate)"
>{{
t(i.i18n_key, i.title)
}}</a>
<a
v-else-if="i.target"
class="tw-mg-r-05"
href="#"
@click.prevent="$emit('change-item', i.target, false)"
>{{
t(i.i18n_key, i.title)
}}</a>
</template>
</span>
</header> </header>
<section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2"> <section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1"> <div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
@ -226,4 +263,4 @@ export default {
} }
} }
</script> </script>

View file

@ -1,7 +1,8 @@
<template> <template>
<div v-if="feed"> <div v-if="feed">
<chat-rich <chat-rich
v-for="entry in feed" v-for="(entry, idx) in feed"
:key="idx"
:data="entry" :data="entry"
class="tw-mg-b-1" class="tw-mg-b-1"
/> />

View file

@ -10,6 +10,7 @@ import type ExperimentManager from '../experiments';
import type SettingsManager from '../settings'; import type SettingsManager from '../settings';
import type PubSubClient from 'utilities/pubsub'; import type PubSubClient from 'utilities/pubsub';
import type { PubSubCommands } from 'utilities/types'; import type { PubSubCommands } from 'utilities/types';
import type { SettingUi_Select_Entry } from '../settings/types';
declare module 'utilities/types' { declare module 'utilities/types' {
interface ModuleMap { interface ModuleMap {
@ -21,6 +22,9 @@ declare module 'utilities/types' {
interface SettingsTypeMap { interface SettingsTypeMap {
'pubsub.use-cluster': keyof typeof PUBSUB_CLUSTERS | null; 'pubsub.use-cluster': keyof typeof PUBSUB_CLUSTERS | null;
} }
interface ExperimentTypeMap {
cf_pubsub: boolean;
}
} }
@ -60,7 +64,7 @@ export default class PubSub extends Module<'pubsub', PubSubEvents> {
this.inject('experiments'); this.inject('experiments');
this.settings.add('pubsub.use-cluster', { this.settings.add('pubsub.use-cluster', {
default: ctx => { default: () => {
if ( this.experiments.getAssignment('cf_pubsub') ) if ( this.experiments.getAssignment('cf_pubsub') )
return 'Staging'; return 'Staging';
return null; return null;
@ -77,7 +81,7 @@ export default class PubSub extends Module<'pubsub', PubSubEvents> {
data: [{ data: [{
value: null, value: null,
title: 'Disabled' title: 'Disabled'
}].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({ } as SettingUi_Select_Entry<string | null>].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
value: x, value: x,
title: x title: x
}))) })))

View file

@ -1238,6 +1238,30 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
parse_path(ui.path) : parse_path(ui.path) :
undefined; undefined;
if ( source && ui.path_tokens && ui.path_tokens.length >= 2 && ui.path_tokens[0].key === 'add_ons' ) {
const addons = this.resolve('addons'),
addon = addons?.getAddon(source);
if ( addon ) {
const test = ui.path_tokens[1] as any,
links: string[] = [];
links.push(`add_ons.changelog.${source}`);
if ( addon.short_name )
links.push(`add_ons.changelog.${addon.short_name.toSnakeCase()}`);
if ( addon.name )
links.push(`add_ons.changelog.${addon.name.toSnakeCase()}`);
test.header_links = [
{
navigate: links,
i18n_key: 'home.changelog',
title: 'Changelog'
}
]
}
}
if ( ! ui.key && key ) if ( ! ui.key && key )
ui.key = key; ui.key = key;

View file

@ -2,7 +2,7 @@ import type SettingsManager from ".";
import type { FilterData } from "../utilities/filtering"; import type { FilterData } from "../utilities/filtering";
import type Logger from "../utilities/logging"; import type Logger from "../utilities/logging";
import type { PathNode } from "../utilities/path-parser"; import type { PathNode } from "../utilities/path-parser";
import type { ExtractSegments, ExtractType, JoinKeyPaths, ObjectKeyPaths, OptionalPromise, OptionallyCallable, RecursivePartial, SettingsTypeMap } from "../utilities/types"; import type { ExtractKey, ExtractSegments, ExtractType, JoinKeyPaths, ObjectKeyPaths, OptionalPromise, OptionallyCallable, PartialPartial, RecursivePartial, SettingsTypeMap } from "../utilities/types";
import type SettingsContext from "./context"; import type SettingsContext from "./context";
import type SettingsProfile from "./profile"; import type SettingsProfile from "./profile";
import type { SettingsProvider } from "./providers"; import type { SettingsProvider } from "./providers";
@ -101,9 +101,28 @@ export type SettingMetadata = {
uses: number[]; uses: number[];
}; };
// Usable Definitions
export type OptionalSettingDefinitionKeys = 'type';
export type ForbiddenSettingDefinitionKeys = '__source' | 'ui';
export type SettingDefinition<T> = Omit<
PartialPartial<FullSettingDefinition<T>, OptionalSettingDefinitionKeys>,
ForbiddenSettingDefinitionKeys
> & {
ui: SettingUiDefinition<T>;
};
export type OptionalSettingUiDefinitionKeys = 'key' | 'path_tokens' | 'i18n_key';
export type ForbiddenSettingUiDefinitionKeys = never;
export type SettingUiDefinition<T> = PartialPartial<FullSettingUiDefinition<T>, OptionalSettingUiDefinitionKeys>;
// Definitions // Definitions
export type SettingDefinition<T> = { export type FullSettingDefinition<T> = {
default: ((ctx: SettingsContext) => T) | T, default: ((ctx: SettingsContext) => T) | T,
type?: string; type?: string;
@ -126,24 +145,55 @@ export type SettingDefinition<T> = {
ui?: SettingUiDefinition<T>; ui?: SettingUiDefinition<T>;
// Reactivity // Reactivity
changed?: () => void; changed?: (value: T) => void;
}; };
export type SettingUiDefinition<T> = {
i18n_key?: string; // UI Definitions
export type SettingUi_Basic = {
key: string; key: string;
path: string; path: string;
path_tokens?: PathNode[]; path_tokens: PathNode[];
component: string; no_filter?: boolean;
force_seen?: boolean;
no_i18n?: boolean; title: string;
i18n_key: string;
// TODO: Handle this better. description?: string;
data: any; desc_i18n_key?: string;
process?: string; /**
* Optional. If present, this method will be used to retrieve an array of
* additional search terms that can be used to search for this setting.
*/
getExtraTerms?: () => string[];
};
// ============================================================================
// Each built-in settings component has a type with extra data definitions.
// ============================================================================
// Text Box
// ============================================================================
export type SettingUi_TextBox = SettingUi_Basic & {
component: 'setting-text-box';
} & (SettingUi_TextBox_Process_Number | SettingUi_TextBox_Process_Other);
// Processing
export type SettingUi_TextBox_Process_Other = {
process?: Exclude<string, 'to_int' | 'to_float'>;
}
export type SettingUi_TextBox_Process_Number = {
process: 'to_int' | 'to_float';
/** /**
* Bounds represents a minimum and maximum numeric value. These values * Bounds represents a minimum and maximum numeric value. These values
@ -156,11 +206,49 @@ export type SettingUiDefinition<T> = {
[low: number, low_inclusive: boolean] | [low: number, low_inclusive: boolean] |
[low: number, high: number] | [low: number, high: number] |
[low: number]; [low: number];
title: string;
description?: string;
} }
// Check Box
// ============================================================================
export type SettingUi_CheckBox = SettingUi_Basic & {
component: 'setting-check-box';
};
// Select Box
// ============================================================================
export type SettingUi_Select<T> = SettingUi_Basic & {
component: 'setting-select-box';
data: OptionallyCallable<[profile: SettingsProfile, current: T], SettingUi_Select_Entry<T>[]>;
}
export type SettingUi_Select_Entry<T> = {
value: T;
title: string;
};
// ============================================================================
// Combined Definitions
// ============================================================================
export type SettingTypeUiDefinition<T> = SettingUi_TextBox | SettingUi_CheckBox | SettingUi_Select<T>;
// We also support other components, if the component doesn't match.
export type SettingOtherUiDefinition = SettingUi_Basic & {
component: Exclude<string, ExtractKey<SettingTypeUiDefinition<any>, 'component'>>;
}
// The final combined definition.
export type FullSettingUiDefinition<T> = SettingTypeUiDefinition<T> | SettingOtherUiDefinition;
// Exports // Exports
export type ExportedSettingsProfile = { export type ExportedSettingsProfile = {

View file

@ -386,10 +386,14 @@ export default class Channel extends Module {
this.fine.searchNode(react, node => { this.fine.searchNode(react, node => {
let state = node?.memoizedState, i = 0; let state = node?.memoizedState, i = 0;
while(state != null && channel == null && i < 50 ) { while(state != null && channel == null && i < 50 ) {
state = state?.next; channel = state?.memoizedState?.current?.result?.data?.user ??
channel = state?.memoizedState?.current?.previous?.result?.data?.user; state?.memoizedState?.current?.previousData?.user;
if (!channel?.lastBroadcast?.game)
if ( !channel?.lastBroadcast?.game )
channel = null; channel = null;
if ( ! channel )
state = state?.next;
i++; i++;
} }
return channel != null; return channel != null;
@ -538,10 +542,11 @@ export default class Channel extends Module {
let state = node?.memoizedState; let state = node?.memoizedState;
i=0; i=0;
while(state != null && channel == null && i < 50) { while(state != null && channel == null && i < 50) {
state = state?.next; channel = state?.memoizedState?.current?.result?.data?.userOrError ??
channel = state?.memoizedState?.current?.currentObservable?.lastResult?.data?.userOrError; state?.memoizedState?.current?.previousData?.userOrError;
if ( ! channel ) if ( ! channel )
channel = state?.memoizedState?.current?.previous?.result?.previousData?.userOrError; state = state?.next;
i++; i++;
} }
node = node?.return; node = node?.return;

View file

@ -247,7 +247,7 @@ export default class Scroller extends Module {
inst.ffz_outside = true; inst.ffz_outside = true;
inst._ffz_accessor = `_ffz_contains_${last_id++}`; inst._ffz_accessor = `_ffz_contains_${last_id++}`;
t.on('tooltips:mousemove', this.ffzTooltipHover, this); t.on('tooltips:hover', this.ffzTooltipHover, this);
t.on('tooltips:leave', this.ffzTooltipLeave, this); t.on('tooltips:leave', this.ffzTooltipLeave, this);
inst.scrollToBottom = function() { inst.scrollToBottom = function() {
@ -682,7 +682,7 @@ export default class Scroller extends Module {
} }
onUnmount(inst) { // eslint-disable-line class-methods-use-this onUnmount(inst) { // eslint-disable-line class-methods-use-this
this.off('tooltips:mousemove', inst.ffzTooltipHover, inst); this.off('tooltips:hover', inst.ffzTooltipHover, inst);
this.off('tooltips:leave', inst.ffzTooltipLeave, inst); this.off('tooltips:leave', inst.ffzTooltipLeave, inst);
if ( inst._ffz_hover_timer ) { if ( inst._ffz_hover_timer ) {
@ -698,4 +698,4 @@ export default class Scroller extends Module {
window.removeEventListener('keydown', inst.ffzHandleKey); window.removeEventListener('keydown', inst.ffzHandleKey);
window.removeEventListener('keyup', inst.ffzHandleKey); window.removeEventListener('keyup', inst.ffzHandleKey);
} }
} }

View file

@ -18,6 +18,15 @@ export const CARD_CONTEXTS = ((e ={}) => {
return e; return e;
})(); })();
export const CONTENT_FLAGS = [
'DrugsIntoxication',
'Gambling',
'MatureGame',
'ProfanityVulgarity',
'SexualThemes',
'ViolentGrpahic'
];
function formatTerms(data, flags) { function formatTerms(data, flags) {
if ( data[0].length ) if ( data[0].length )
data[1].push(addWordSeparators(data[0].join('|'))); data[1].push(addWordSeparators(data[0].join('|')));
@ -253,6 +262,115 @@ export default class Directory extends Module {
changed: () => this.updateCards() changed: () => this.updateCards()
}); });
this.settings.add('directory.blur-titles', {
default: [],
type: 'array_merge',
always_inherit: true,
ui: {
path: 'Directory > Channels >> Hide Thumbnails by Title',
component: 'basic-terms'
}
});
this.settings.add('__filter:directory.blur-titles', {
requires: ['directory.blur-titles'],
equals: 'requirements',
process(ctx) {
const val = ctx.get('directory.blur-titles');
if ( ! val || ! val.length )
return null;
const out = [
[ // sensitive
[], [] // word
],
[
[], []
]
];
for(const item of val) {
const t = item.t;
let v = item.v;
if ( t === 'glob' )
v = glob_to_regex(v);
else if ( t !== 'raw' )
v = escape_regex(v);
if ( ! v || ! v.length )
continue;
out[item.s ? 0 : 1][item.w ? 0 : 1].push(v);
}
return [
formatTerms(out[0], 'g'),
formatTerms(out[1], 'gi')
];
},
changed: () => this.updateCards()
});
this.settings.add('directory.blur-tags', {
default: [],
type: 'basic_array_merge',
always_inherit: true,
ui: {
path: 'Directory > Channels >> Hide Thumbnails by Tag',
component: 'tag-list-editor'
},
changed: () => this.updateCards()
});
this.settings.add('directory.block-flags', {
default: [],
type: 'array_merge',
always_inherit: true,
process(ctx, val) {
const out = new Set;
for(const v of val)
if ( v?.v )
out.add(v.v);
return out;
},
ui: {
path: 'Directory > Channels >> Block by Flag',
component: 'blocked-types',
data: () => [...CONTENT_FLAGS]
.sort()
},
changed: () => this.updateCards()
});
this.settings.add('directory.blur-flags', {
default: [],
type: 'array_merge',
always_inherit: true,
process(ctx, val) {
const out = new Set;
for(const v of val)
if ( v?.v )
out.add(v.v);
return out;
},
ui: {
path: 'Directory > Channels >> Hide Thumbnails by Flag',
component: 'blocked-types',
data: () => [...CONTENT_FLAGS]
.sort()
},
changed: () => this.updateCards()
});
/*this.settings.add('directory.hide-viewing-history', { /*this.settings.add('directory.hide-viewing-history', {
default: false, default: false,
ui: { ui: {
@ -458,23 +576,61 @@ export default class Directory extends Module {
const game = props.gameTitle || props.trackingProps?.categoryName || props.trackingProps?.category || props.contextualCardActionProps?.props?.categoryName, const game = props.gameTitle || props.trackingProps?.categoryName || props.trackingProps?.category || props.contextualCardActionProps?.props?.categoryName,
tags = props.tagListProps?.freeformTags; tags = props.tagListProps?.freeformTags;
let bad_tag = false; const blur_flags = this.settings.get('directory.blur-flags', []),
block_flags = this.settings.get('directory.block-flags', []);
el.classList.toggle('ffz-hide-thumbnail', this.settings.provider.get('directory.game.hidden-thumbnails', []).includes(game)); if ( el._ffz_flags === undefined && (blur_flags.size || block_flags.size) ) {
el.dataset.ffzType = props.streamType; el._ffz_flags = null;
this.twitch_data.getStreamFlags(null, props.channelLogin).then(data => {
el._ffz_flags = data;
this.updateCard(el);
});
}
let bad_tag = false,
blur_tag = false;
if ( Array.isArray(tags) ) { if ( Array.isArray(tags) ) {
const bad_tags = this.settings.get('directory.blocked-tags', []); const bad_tags = this.settings.get('directory.blocked-tags', []),
if ( bad_tags.length ) { blur_tags = this.settings.get('directory.blur-tags', []);
if ( bad_tags.length || blur_tags.length ) {
for(const tag of tags) { for(const tag of tags) {
if ( tag?.name && bad_tags.includes(tag.name.toLowerCase()) ) { if ( tag?.name ) {
bad_tag = true; const lname = tag.name.toLowerCase();
break; if ( bad_tags.includes(lname) )
bad_tag = true;
if ( blur_tags.includes(lname) )
blur_tag = true;
} }
if ( (bad_tag || ! bad_tags.length) && (blur_tag || ! blur_tags.length) )
break;
} }
} }
} }
let should_blur = blur_tag;
if ( ! should_blur )
should_blur = this.settings.provider.get('directory.game.hidden-thumbnails', []).includes(game);
if ( ! should_blur && blur_flags.size && el._ffz_flags ) {
for(const flag of el._ffz_flags)
if ( flag?.id && blur_flags.has(flag.id) ) {
should_blur = true;
break;
}
}
if ( ! should_blur ) {
const regexes = this.settings.get('__filter:directory.blur-titles');
if ( regexes &&
(( regexes[0] && regexes[0].test(props.title) ) ||
( regexes[1] && regexes[1].test(props.title) ))
)
should_blur = true;
}
el.classList.toggle('ffz-hide-thumbnail', should_blur);
el.dataset.ffzType = props.streamType;
let should_hide = false; let should_hide = false;
if ( bad_tag ) if ( bad_tag )
should_hide = true; should_hide = true;
@ -485,12 +641,22 @@ export default class Directory extends Module {
else if ( (props.isPromotion || props.sourceType === 'COMMUNITY_BOOST' || props.sourceType === 'PROMOTION' || props.sourceType === 'SPONSORED') && this.settings.get('directory.hide-promoted') ) else if ( (props.isPromotion || props.sourceType === 'COMMUNITY_BOOST' || props.sourceType === 'PROMOTION' || props.sourceType === 'SPONSORED') && this.settings.get('directory.hide-promoted') )
should_hide = true; should_hide = true;
else { else {
const regexes = this.settings.get('__filter:directory.block-titles'); if ( block_flags.size && el._ffz_flags ) {
if ( regexes && for(const flag of el._ffz_flags)
(( regexes[0] && regexes[0].test(props.title) ) || if ( flag?.id && block_flags.has(flag.id) ) {
( regexes[1] && regexes[1].test(props.title) )) should_hide = true;
) break;
should_hide = true; }
}
if ( ! should_hide ) {
const regexes = this.settings.get('__filter:directory.block-titles');
if ( regexes &&
(( regexes[0] && regexes[0].test(props.title) ) ||
( regexes[1] && regexes[1].test(props.title) ))
)
should_hide = true;
}
} }
let hide_container = el.closest('.tw-tower > div'); let hide_container = el.closest('.tw-tower > div');
@ -642,4 +808,4 @@ export default class Directory extends Module {
this.router.navigate('user', { userName: user }); this.router.navigate('user', { userName: user });
} }
} }

View file

@ -109,11 +109,10 @@ export default class ModView extends Module {
let state = node.memoizedState; let state = node.memoizedState;
i = 0; i = 0;
while(state != null && channel == null && i < 50) { while(state != null && channel == null && i < 50) {
channel = state?.memoizedState?.current?.result?.data?.user ??
state?.memoizedState?.current?.previousData?.user;
state = state?.next; state = state?.next;
//channel = state?.memoizedState?.current?.previousData?.result?.data?.user;
channel = state?.memoizedState?.current?.currentObservable?.lastResult?.data?.user;
if ( ! channel )
channel = state?.memoizedState?.current?.previous?.result?.previousData?.user;
i++; i++;
} }
node = node?.child; node = node?.child;
@ -226,8 +225,9 @@ export default class ModView extends Module {
let channel = null, state = root?.return?.memoizedState, i = 0; let channel = null, state = root?.return?.memoizedState, i = 0;
while(state != null && channel == null && i < 50 ) { while(state != null && channel == null && i < 50 ) {
channel = state?.memoizedState?.current?.result?.data?.channel ??
state?.memoizedState?.current?.previousData?.channel;
state = state?.next; state = state?.next;
channel = state?.memoizedState?.current?.previous?.result?.data?.channel;
i++; i++;
} }
@ -337,4 +337,4 @@ export default class ModView extends Module {
} }
} }

View file

@ -9,6 +9,12 @@ import Module, { GenericModule } from 'utilities/module';
import {ManagedStyle} from 'utilities/dom'; import {ManagedStyle} from 'utilities/dom';
import {has, once} from 'utilities/object'; import {has, once} from 'utilities/object';
declare module "utilities/types" {
interface ModuleMap {
'site.css_tweaks': CSSTweaks;
}
}
/** /**
* CSS Tweaks is a somewhat generic module for handling FrankerFaceZ's CSS * CSS Tweaks is a somewhat generic module for handling FrankerFaceZ's CSS
* injection. It can load and unload specific blocks of CSS, as well as * injection. It can load and unload specific blocks of CSS, as well as

View file

@ -0,0 +1,13 @@
query FFZ_StreamFlags($ids: [ID!], $logins: [String!]) {
users(ids: $ids, logins: $logins) {
id
login
stream {
id
contentClassificationLabels {
id
localizedName
}
}
}
}

View file

@ -443,13 +443,13 @@ export class ManagedStyle {
} }
export class ClickOutside<TFunc extends (event: MouseEvent) => void> { export class ClickOutside {
el: HTMLElement | null; el: HTMLElement | null;
cb: TFunc | null; cb: ((event: MouseEvent) => void) | null;
_fn: ((event: MouseEvent) => void) | null; _fn: ((event: MouseEvent) => void) | null;
constructor(element: HTMLElement, callback: TFunc) { constructor(element: HTMLElement, callback: ((event: MouseEvent) => void)) {
this.el = element; this.el = element;
this.cb = callback; this.cb = callback;

View file

@ -1136,6 +1136,12 @@ export class SourcedSet<T> {
/** Check to see if a specific source has any values. */ /** Check to see if a specific source has any values. */
has(source: string) { return this._sources ? this._sources.has(source) : false } has(source: string) { return this._sources ? this._sources.has(source) : false }
*iterateSources() {
if ( this._sources )
for(const entry of this._sources)
yield entry;
}
/** Check to see if a specific source has a specific value. */ /** Check to see if a specific source has a specific value. */
sourceIncludes(source: string, value: T) { sourceIncludes(source: string, value: T) {
const src = this._sources && this._sources.get(source); const src = this._sources && this._sources.get(source);

View file

@ -43,6 +43,11 @@ export default class TwitchData extends Module {
private _waiting_user_logins: Map<string, unknown>; private _waiting_user_logins: Map<string, unknown>;
private _waiting_stream_ids: Map<string, unknown>; private _waiting_stream_ids: Map<string, unknown>;
private _waiting_stream_logins: Map<string, unknown>; private _waiting_stream_logins: Map<string, unknown>;
private _waiting_flag_ids: Map<string, unknown>;
private _waiting_flag_logins: Map<string, unknown>;
private _loading_streams?: boolean;
private _loading_flags?: boolean;
private tag_cache: Map<string, unknown>; private tag_cache: Map<string, unknown>;
private _waiting_tags: Map<string, unknown>; private _waiting_tags: Map<string, unknown>;
@ -60,6 +65,9 @@ export default class TwitchData extends Module {
this._waiting_stream_ids = new Map; this._waiting_stream_ids = new Map;
this._waiting_stream_logins = new Map; this._waiting_stream_logins = new Map;
this._waiting_flag_ids = new Map;
this._waiting_flag_logins = new Map;
this.tag_cache = new Map; this.tag_cache = new Map;
this._waiting_tags = new Map; this._waiting_tags = new Map;
@ -871,6 +879,146 @@ export default class TwitchData extends Module {
} }
// ========================================================================
// Stream Content Flags (for Directory Purposes)
// ========================================================================
/**
* Queries Apollo for stream content flags. One of (id, login) MUST be specified
*
* @param id - the channel id number (can be an integer string)
* @param login - the channel name
*/
getStreamFlags(id: string | number): Promise<any>;
getStreamFlags(id: null, login: string): Promise<any>;
getStreamFlags(id: string | number | null, login?: string | null) {
return new Promise((s, f) => {
if ( typeof id === 'number' )
id = `${id}`;
if ( id ) {
const existing = this._waiting_flag_ids.get(id);
if ( existing )
existing.push([s,f]);
else
this._waiting_flag_ids.set(id, [[s,f]]);
} else if ( login ) {
const existing = this._waiting_flag_logins.get(login);
if ( existing )
existing.push([s,f]);
else
this._waiting_flag_logins.set(login, [[s,f]]);
} else
f('id and login cannot both be null');
if ( ! this._loading_flags )
this._loadFlags();
})
}
async _loadFlags() {
if ( this._loading_flags )
return;
this._loading_flags = true;
// Get the first 50... things.
const ids = [...this._waiting_flag_ids.keys()].slice(0, 50),
remaining = 50 - ids.length,
logins = remaining > 0 ? [...this._waiting_flag_logins.keys()].slice(0, remaining) : [];
let nodes;
try {
const data = await this.queryApollo({
query: await import(/* webpackChunkName: 'queries' */ './data/stream-flags.gql'),
variables: {
ids: ids.length ? ids : null,
logins: logins.length ? logins : null
}
});
nodes = get('data.users', data);
} catch(err) {
for(const id of ids) {
const promises = this._waiting_flag_ids.get(id);
if ( promises ) {
this._waiting_flag_ids.delete(id);
for(const pair of promises)
pair[1](err);
}
}
for(const login of logins) {
const promises = this._waiting_flag_logins.get(login);
if ( promises ) {
this._waiting_flag_logins.delete(login);
for(const pair of promises)
pair[1](err);
}
}
return;
}
const id_set = new Set(ids),
login_set = new Set(logins);
if ( Array.isArray(nodes) )
for(const node of nodes) {
if ( ! node || ! node.id )
continue;
id_set.delete(node.id);
login_set.delete(node.login);
let promises = this._waiting_flag_ids.get(node.id);
if ( promises ) {
this._waiting_flag_ids.delete(node.id);
for(const pair of promises)
pair[0](node.stream?.contentClassificationLabels);
}
promises = this._waiting_flag_logins.get(node.login);
if ( promises ) {
this._waiting_flag_logins.delete(node.login);
for(const pair of promises)
pair[0](node.stream?.contentClassificationLabels);
}
}
for(const id of id_set) {
const promises = this._waiting_flag_ids.get(id);
if ( promises ) {
this._waiting_flag_ids.delete(id);
for(const pair of promises)
pair[0](null);
}
}
for(const login of login_set) {
const promises = this._waiting_flag_logins.get(login);
if ( promises ) {
this._waiting_flag_logins.delete(login);
for(const pair of promises)
pair[0](null);
}
}
this._loading_flags = false;
if ( this._waiting_flag_ids.size || this._waiting_flag_logins.size )
this._loadFlags();
}
// ======================================================================== // ========================================================================
// Tags // Tags
// ======================================================================== // ========================================================================

View file

@ -10,16 +10,10 @@ import type EmoteCard from "../modules/emote_card";
import type LinkCard from "../modules/link_card"; import type LinkCard from "../modules/link_card";
import type MainMenu from "../modules/main_menu"; import type MainMenu from "../modules/main_menu";
import type TranslationUI from "../modules/translation_ui"; import type TranslationUI from "../modules/translation_ui";
import type PubSub from "../pubsub";
import type SocketClient from "../socket"; import type SocketClient from "../socket";
import type StagingSelector from "../staging";
import type Apollo from "./compat/apollo"; import type Apollo from "./compat/apollo";
import type Elemental from "./compat/elemental";
import type Fine from "./compat/fine";
import type WebMunch from "./compat/webmunch"; import type WebMunch from "./compat/webmunch";
import type CSSTweaks from "./css-tweaks";
import type { NamespacedEvents } from "./events"; import type { NamespacedEvents } from "./events";
import type TwitchData from "./twitch-data";
/** /**
* AddonInfo represents the data contained in an add-on's manifest. * AddonInfo represents the data contained in an add-on's manifest.
@ -42,18 +36,23 @@ export type AddonInfo = {
/** The human-readable name of the add-on, in English. */ /** The human-readable name of the add-on, in English. */
name: string; name: string;
name_i18n?: string;
/** Optional. A human-readable shortened name for the add-on, in English. */ /** Optional. A human-readable shortened name for the add-on, in English. */
short_name?: string; short_name?: string;
short_name_i18n?: string;
/** The name of the add-on's author. */ /** The name of the add-on's author. */
author: string; author: string;
author_i18n?: string;
/** The name of the person or persons maintaining the add-on, if different than the author. */ /** The name of the person or persons maintaining the add-on, if different than the author. */
maintainer?: string; maintainer?: string;
maintainer_i18n?: string;
/** A description of the add-on. This can be multiple lines and supports Markdown. */ /** A description of the add-on. This can be multiple lines and supports Markdown. */
description: string; description: string;
description_i18n?: string;
/** Optional. A settings UI key. If set, a Settings button will be displayed for this add-on that takes the user to this add-on's settings. */ /** Optional. A settings UI key. If set, a Settings button will be displayed for this add-on that takes the user to this add-on's settings. */
settings?: string; settings?: string;
@ -61,6 +60,9 @@ export type AddonInfo = {
/** Optional. This add-on's website. If set, a Website button will be displayed that functions as a link. */ /** Optional. This add-on's website. If set, a Website button will be displayed that functions as a link. */
website?: string; website?: string;
/** Optional. List of additional terms that can be searched for to find the add-on. */
search_terms?: string | null;
/** The date when the add-on was first created. */ /** The date when the add-on was first created. */
created: Date; created: Date;
@ -86,6 +88,9 @@ export type AddonInfo = {
/** List of FrankerFaceZ flavors ("main", "clips", "player") that this add-on supports. */ /** List of FrankerFaceZ flavors ("main", "clips", "player") that this add-on supports. */
targets: string[]; targets: string[];
/** Optional. List of load tracker events that this add-on should hold while it's being loaded. */
load_events?: string[];
}; };
// These types are used by get() // These types are used by get()
@ -125,6 +130,15 @@ export type ExtractEach<T, Rest> =
: T; : T;
export type ExtractKey<T,K> = K extends keyof T ? T[K] : never;
export type PartialPartial<T, OptionalKeys extends keyof T> = {
[K in keyof T as K extends OptionalKeys ? never : K]: T[K];
} & {
[K in OptionalKeys]?: T[K];
};
export type AnyFunction = (...args: any[]) => any; export type AnyFunction = (...args: any[]) => any;
@ -229,15 +243,13 @@ export interface PubSubCommands {
}; };
export interface ExperimentTypeMap {
never: unknown;
// TODO: Move this event into addons.
type AddonEvent = {
'addon:fully-unload': [addon_id: string]
}; };
export interface ModuleEventMap {
export interface ModuleEventMap { }; };
export interface ModuleMap { export interface ModuleMap {
'chat': Chat; 'chat': Chat;
@ -252,14 +264,13 @@ export interface ModuleMap {
'link_card': LinkCard; 'link_card': LinkCard;
'main_menu': MainMenu; 'main_menu': MainMenu;
'site.apollo': Apollo; 'site.apollo': Apollo;
'site.css_tweaks': CSSTweaks;
'site.web_munch': WebMunch; 'site.web_munch': WebMunch;
'socket': SocketClient; 'socket': SocketClient;
'translation_ui': TranslationUI; 'translation_ui': TranslationUI;
}; };
export type KnownEvents = AddonEvent & UnionToIntersection<{ export type KnownEvents = UnionToIntersection<{
[K in keyof ModuleEventMap]: NamespacedEvents<K, ModuleEventMap[K]> [K in keyof ModuleEventMap]: NamespacedEvents<K, ModuleEventMap[K]>
}[keyof ModuleEventMap]>; }[keyof ModuleEventMap]>;

View file

@ -183,6 +183,20 @@ textarea.ffz-input {
font-weight: bold; font-weight: bold;
color: inherit !important; color: inherit !important;
} }
.ffz--menu-page__header-links {
color: var(--color-text-alt-2);
a {
font-weight: normal;
color: var(--color-text-alt) !important;
&:focus,&:hover {
color: var(--color-text-link) !important;
}
}
}
} }
} }
@ -534,4 +548,4 @@ textarea.ffz-input {
.ffz-mh-30 { .ffz-mh-30 {
max-height: 30rem; max-height: 30rem;
} }