1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 12:55:55 +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",
"author": "Dan Salvato LLC",
"version": "4.60.1",
"version": "4.61.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true,
"license": "Apache-2.0",

View file

@ -46,6 +46,12 @@ type AddonManagerEvents = {
};
type FullAddonInfo = AddonInfo & {
_search?: string | null;
src: string;
};
// ============================================================================
// AddonManager
// ============================================================================
@ -62,7 +68,7 @@ export default class AddonManager extends Module<'addons'> {
reload_required: boolean;
target: string;
addons: Record<string, AddonInfo | string[]>;
addons: Record<string, FullAddonInfo | string[]>;
enabled_addons: string[];
private _loader?: Promise<void>;
@ -239,7 +245,9 @@ export default class AddonManager extends Module<'addons'> {
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];
this.addons[addon.id] = addon;
@ -269,7 +277,7 @@ export default class AddonManager extends Module<'addons'> {
this.addons[id] = [addon.id];
}
if ( ! old )
if ( ! old || Array.isArray(old) )
this.settings.addUI(`addon-changelog.${addon.id}`, {
path: `Add-Ons > Changelog > ${addon.name}`,
component: 'changelog',
@ -284,6 +292,9 @@ export default class AddonManager extends Module<'addons'> {
rebuildAddonSearch() {
for(const addon of Object.values(this.addons)) {
if ( Array.isArray(addon) )
continue;
const terms = new Set([
addon._search,
addon.name,
@ -302,11 +313,15 @@ export default class AddonManager extends Module<'addons'> {
if ( addon.author_i18n )
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 )
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 type SettingsManager from './settings';
import type { ExperimentTypeMap } from 'utilities/types';
declare module 'utilities/types' {
interface ModuleMap {
@ -22,7 +23,16 @@ declare module 'utilities/types' {
experiments: ExperimentEvents;
}
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 = {
':changed': [key: string, new_value: any, old_value: any];
':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];
} & {
[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;
}
function sortExperimentLog(a,b) {
function sortExperimentLog(a: ExperimentLogEntry, b: ExperimentLogEntry) {
if ( a.rarity < b.rarity )
return -1;
else if ( a.rarity > b.rarity )
@ -133,9 +154,11 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
// State
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
@ -155,19 +178,20 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
no_filter: true,
getExtraTerms: () => {
const values = [];
const values: string[] = [];
for(const exps of [this.experiments, this.getTwitchExperiments()]) {
if ( ! exps )
continue;
for(const [key, val] of Object.entries(this.experiments)) {
values.push(key);
if ( val.name )
values.push(val.name);
if ( val.description )
values.push(val.description);
}
for(const [key, val] of Object.entries(exps)) {
values.push(key);
if ( val.name )
values.push(val.name);
if ( val.description )
values.push(val.description);
}
for(const [key, val] of Object.entries(this.getTwitchExperiments())) {
values.push(key);
if ( val.name )
values.push(val.name);
}
return values;
@ -178,7 +202,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
unique_id: () => this.unique_id,
ffz_data: () => deep_copy(this.experiments) ?? {},
ffz_data: () => deep_copy(this.experiments),
twitch_data: () => deep_copy(this.getTwitchExperiments()),
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),
deleteTwitchOverride: (key: string) => this.deleteTwitchOverride(key),
getAssignment: (key: string) => this.getAssignment(key),
hasOverride: (key: string) => this.hasOverride(key),
setOverride: (key: string, val: any) => this.setOverride(key, val),
deleteOverride: (key: string) => this.deleteOverride(key),
getAssignment: <K extends keyof ExperimentTypeMap>(key: K) => this.getAssignment(key),
hasOverride: (key: keyof ExperimentTypeMap) => this.hasOverride(key),
setOverride: <K extends keyof ExperimentTypeMap>(key: K, val: ExperimentTypeMap[K]) => this.setOverride(key, val),
deleteOverride: (key: keyof ExperimentTypeMap) => this.deleteOverride(key),
on: (...args: Parameters<typeof this.on>) => this.on(...args),
off: (...args: Parameters<typeof this.off>) => this.off(...args)
@ -226,7 +250,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
async loadExperiments() {
let data: Record<string, FFZExperimentData> | null;
let data: Record<keyof ExperimentTypeMap, FFZExperimentData> | null;
try {
data = await fetchJSON(DEBUG
@ -254,7 +278,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
if ( old_val !== new_val ) {
changed++;
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 */
onEnable() {
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);
if ( Array.isArray(data) ) {
if ( ! this.experiments[key] )
const existing = this.experiments[key];
if ( ! existing )
return;
this.experiments[key].groups = data;
existing.groups = data;
} else if ( data?.groups )
this.experiments[key] = data;
@ -291,8 +318,8 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
''
];
const ffz_assignments = [];
for(const [key, value] of Object.entries(this.experiments)) {
const ffz_assignments: ExperimentLogEntry[] = [];
for(const [key, value] of Object.entries(this.experiments) as [keyof ExperimentTypeMap, FFZExperimentData][]) {
const assignment = this.getAssignment(key),
override = this.hasOverride(key);
@ -322,7 +349,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
for(const entry of ffz_assignments)
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');
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);
}
_rebuildTwitchKey(
private _rebuildTwitchKey(
key: string,
is_set: boolean,
new_val: string | null
@ -578,7 +605,9 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
// 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', {});
overrides[key] = value;
@ -587,7 +616,7 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
this._rebuildKey(key);
}
deleteOverride(key: string) {
deleteOverride(key: keyof ExperimentTypeMap) {
const overrides = this.settings.provider.get('experiment-overrides');
if ( ! overrides || ! has(overrides, key) )
return;
@ -601,33 +630,37 @@ export default class ExperimentManager extends Module<'experiments', ExperimentE
this._rebuildKey(key);
}
hasOverride(key: string) {
hasOverride(key: keyof ExperimentTypeMap) {
const overrides = this.settings.provider.get('experiment-overrides');
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) )
return this.cache.get(key) as T;
return this.cache.get(key) as ExperimentTypeMap[K];
const experiment = this.experiments[key];
if ( ! experiment ) {
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'),
out = overrides && has(overrides, key) ?
overrides[key] as T :
ExperimentManager.selectGroup<T>(key, experiment, this.unique_id ?? '');
overrides[key] :
ExperimentManager.selectGroup<ExperimentTypeMap[K]>(key, experiment, this.unique_id ?? '');
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) )
return;

View file

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

View file

@ -4,14 +4,43 @@
// Name and Color Overrides
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import { createElement, ClickOutside } from 'utilities/dom';
import Tooltip from 'utilities/tooltip';
import type SettingsManager from 'root/src/settings';
export default class Overrides extends Module {
constructor(...args) {
super(...args);
declare module 'utilities/types' {
interface ModuleMap {
'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');
@ -35,12 +64,15 @@ export default class Overrides extends Module {
});*/
}
/** @internal */
onEnable() {
this.settings.provider.on('changed', this.onProviderChange, this);
}
renderUserEditor(user, target) {
let outside, popup, ve;
renderUserEditor(user: any, target: HTMLElement) {
let outside: ClickOutside | null,
popup: Tooltip | null,
ve: any;
const destroy = () => {
const o = outside, p = popup, v = ve;
@ -56,7 +88,10 @@ export default class Overrides extends Module {
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, [], {
logger: this.log,
@ -88,6 +123,9 @@ export default class Overrides extends Module {
const vue = this.resolve('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]);
vue.component('override-editor', editor.default);
@ -118,12 +156,13 @@ export default class Overrides extends Module {
onShow: async (t, tip) => {
await tip.waitForDom();
requestAnimationFrame(() => {
outside = new ClickOutside(tip.outer, destroy)
if ( tip.outer )
outside = new ClickOutside(tip.outer, destroy)
});
},
onMove: (target, tip, event) => {
this.emit('tooltips:mousemove', target, tip, event)
this.emit('tooltips:hover', target, tip, event)
},
onLeave: (target, tip, event) => {
@ -137,30 +176,25 @@ export default class Overrides extends Module {
}
onProviderChange(key) {
if ( key === 'overrides.colors' )
onProviderChange(key: string) {
if ( key === 'overrides.colors' && this.color_cache )
this.loadColors();
else if ( key === 'overrides.names' )
else if ( key === 'overrides.names' && this.name_cache )
this.loadNames();
}
get colors() {
if ( ! this.color_cache )
this.loadColors();
return this.color_cache;
return this.color_cache ?? this.loadColors();
}
get names() {
if ( ! this.name_cache )
this.loadNames();
return this.name_cache;
return this.name_cache ?? this.loadNames();
}
loadColors() {
let old_keys,
let old_keys: Set<string>,
loaded = true;
if ( ! this.color_cache ) {
loaded = false;
this.color_cache = {};
@ -168,24 +202,28 @@ export default class Overrides extends Module {
} else
old_keys = new Set(Object.keys(this.color_cache));
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.colors', {}))) {
old_keys.delete(key);
if ( this.color_cache[key] !== val ) {
this.color_cache[key] = val;
if ( loaded )
this.emit(':changed', key, 'color', val);
const entries = this.settings.provider.get('overrides.colors');
if ( entries )
for(const [key, val] of Object.entries(entries)) {
old_keys.delete(key);
if ( this.color_cache[key] !== val ) {
this.color_cache[key] = val;
if ( loaded )
this.emit(':changed', key, 'color', val);
}
}
}
for(const key of old_keys) {
this.color_cache[key] = undefined;
if ( loaded )
this.emit(':changed', key, 'color', undefined);
}
return this.color_cache;
}
loadNames() {
let old_keys,
let old_keys: Set<string>,
loaded = true;
if ( ! this.name_cache ) {
loaded = false;
@ -194,37 +232,35 @@ export default class Overrides extends Module {
} else
old_keys = new Set(Object.keys(this.name_cache));
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.names', {}))) {
old_keys.delete(key);
if ( this.name_cache[key] !== val ) {
this.name_cache[key] = val;
if ( loaded )
this.emit(':changed', key, 'name', val);
const entries = this.settings.provider.get('overrides.names');
if ( entries )
for(const [key, val] of Object.entries(entries)) {
old_keys.delete(key);
if ( this.name_cache[key] !== val ) {
this.name_cache[key] = val;
if ( loaded )
this.emit(':changed', key, 'name', val);
}
}
}
for(const key of old_keys) {
this.name_cache[key] = undefined;
if ( loaded )
this.emit(':changed', key, 'name', undefined);
}
return this.name_cache;
}
getColor(id) {
if ( this.colors[id] != null )
return this.colors[id];
return null;
getColor(id: string): string | null {
return this.colors[id] ?? null;
}
getName(id) {
if ( this.names[id] != null )
return this.names[id];
return null;
getName(id: string) {
return this.names[id] ?? null;
}
setColor(id, color) {
setColor(id: string, color?: string) {
if ( this.colors[id] !== color ) {
this.colors[id] = color;
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 ) {
this.names[id] = name;
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);
}
deleteName(id) {
deleteName(id: string) {
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 type Chat from '.';
import type Room from './room';
import type { BadgeAssignment } from './types';
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.room = room;
this.emote_sets = null; //new SourcedSet;
this.badges = null; // new SourcedSet;
this.emote_sets = null;
this.badges = null;
this._id = id;
this.login = login;
if ( id )
(room || manager).user_ids[id] = this;
(room ?? manager).user_ids[id] = this;
}
destroy() {
@ -31,6 +50,7 @@ export default class User {
this.emote_sets = null;
}
// Badges are not referenced, so we can just dump them all.
if ( this.badges )
this.badges = null;
@ -45,26 +65,24 @@ export default class User {
}
}
merge(other) {
merge(other: User) {
if ( ! this.login && other.login )
this.login = other.login;
if ( other.emote_sets && other.emote_sets._sources ) {
for(const [provider, sets] of other.emote_sets._sources.entries()) {
if ( other.emote_sets )
for(const [provider, sets] of other.emote_sets.iterateSources()) {
for(const set_id of sets)
this.addSet(provider, set_id);
}
}
if ( other.badges && other.badges._sources ) {
for(const [provider, badges] of other.badges._sources.entries()) {
if ( other.badges )
for(const [provider, badges] of other.badges.iterateSources()) {
for(const badge of badges)
this.addBadge(provider, badge.id, badge);
}
}
}
_unloadAddon(addon_id) {
_unloadAddon(addon_id: string) {
// TODO: This
return 0;
}
@ -107,9 +125,9 @@ export default class User {
// Add Badges
// ========================================================================
addBadge(provider, badge_id, data) {
addBadge(provider: string, badge_id: string, data?: BadgeAssignment) {
if ( this.destroyed )
return;
return false;
if ( typeof badge_id === 'number' )
badge_id = `${badge_id}`;
@ -122,8 +140,9 @@ export default class User {
if ( ! this.badges )
this.badges = new SourcedSet;
if ( this.badges.has(provider) )
for(const old_b of this.badges.get(provider))
const existing = this.badges.get(provider);
if ( existing )
for(const old_b of existing)
if ( old_b.id == badge_id ) {
Object.assign(old_b, data);
return false;
@ -135,31 +154,35 @@ export default class User {
}
getBadge(badge_id) {
if ( ! this.badges )
return null;
getBadge(badge_id: string) {
if ( this.badges )
for(const badge of this.badges._cache)
if ( badge.id == badge_id )
return badge;
for(const badge of this.badges._cache)
if ( badge.id == badge_id )
return badge;
return null;
}
removeBadge(provider, badge_id) {
if ( ! this.badges || ! this.badges.has(provider) )
removeBadge(provider: string, badge_id: string) {
if ( ! this.badges )
return false;
for(const old_b of this.badges.get(provider))
if ( old_b.id == badge_id ) {
this.badges.remove(provider, old_b);
//this.manager.badges.unrefBadge(badge_id);
return true;
}
const existing = this.badges.get(provider);
if ( existing )
for(const old_b of existing)
if ( old_b.id == badge_id ) {
this.badges.remove(provider, old_b);
//this.manager.badges.unrefBadge(badge_id);
return true;
}
return false;
}
removeAllBadges(provider) {
if ( this.destroyed || ! this.badges )
removeAllBadges(provider: string) {
if ( ! this.badges )
return false;
if ( ! this.badges.has(provider) )
@ -175,7 +198,7 @@ export default class User {
// Emote Sets
// ========================================================================
addSet(provider, set_id, data) {
addSet(provider: string, set_id: string, data?: unknown) {
if ( this.destroyed )
return;
@ -203,8 +226,8 @@ export default class User {
return added;
}
removeAllSets(provider) {
if ( this.destroyed || ! this.emote_sets )
removeAllSets(provider: string) {
if ( ! this.emote_sets )
return false;
const sets = this.emote_sets.get(provider);
@ -217,8 +240,8 @@ export default class User {
return true;
}
removeSet(provider, set_id) {
if ( this.destroyed || ! this.emote_sets )
removeSet(provider: string, set_id: string) {
if ( ! this.emote_sets )
return;
if ( typeof set_id === 'number' )
@ -235,4 +258,4 @@ export default class User {
return false;
}
}
}

View file

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

View file

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

View file

@ -2,10 +2,47 @@
<div class="ffz--menu-page">
<header class="tw-mg-b-1">
<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>
<template v-if="i !== item">&raquo; </template>
</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>
<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">
@ -226,4 +263,4 @@ export default {
}
}
</script>
</script>

View file

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

View file

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

View file

@ -1238,6 +1238,30 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
parse_path(ui.path) :
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 )
ui.key = key;

View file

@ -2,7 +2,7 @@ import type SettingsManager from ".";
import type { FilterData } from "../utilities/filtering";
import type Logger from "../utilities/logging";
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 SettingsProfile from "./profile";
import type { SettingsProvider } from "./providers";
@ -101,9 +101,28 @@ export type SettingMetadata = {
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
export type SettingDefinition<T> = {
export type FullSettingDefinition<T> = {
default: ((ctx: SettingsContext) => T) | T,
type?: string;
@ -126,24 +145,55 @@ export type SettingDefinition<T> = {
ui?: SettingUiDefinition<T>;
// Reactivity
changed?: () => void;
changed?: (value: T) => void;
};
export type SettingUiDefinition<T> = {
i18n_key?: string;
// UI Definitions
export type SettingUi_Basic = {
key: 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.
data: any;
description?: string;
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
@ -156,11 +206,49 @@ export type SettingUiDefinition<T> = {
[low: number, low_inclusive: boolean] |
[low: number, high: 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
export type ExportedSettingsProfile = {

View file

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

View file

@ -247,7 +247,7 @@ export default class Scroller extends Module {
inst.ffz_outside = true;
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);
inst.scrollToBottom = function() {
@ -682,7 +682,7 @@ export default class Scroller extends Module {
}
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);
if ( inst._ffz_hover_timer ) {
@ -698,4 +698,4 @@ export default class Scroller extends Module {
window.removeEventListener('keydown', inst.ffzHandleKey);
window.removeEventListener('keyup', inst.ffzHandleKey);
}
}
}

View file

@ -18,6 +18,15 @@ export const CARD_CONTEXTS = ((e ={}) => {
return e;
})();
export const CONTENT_FLAGS = [
'DrugsIntoxication',
'Gambling',
'MatureGame',
'ProfanityVulgarity',
'SexualThemes',
'ViolentGrpahic'
];
function formatTerms(data, flags) {
if ( data[0].length )
data[1].push(addWordSeparators(data[0].join('|')));
@ -253,6 +262,115 @@ export default class Directory extends Module {
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', {
default: false,
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,
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));
el.dataset.ffzType = props.streamType;
if ( el._ffz_flags === undefined && (blur_flags.size || block_flags.size) ) {
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) ) {
const bad_tags = this.settings.get('directory.blocked-tags', []);
if ( bad_tags.length ) {
const bad_tags = this.settings.get('directory.blocked-tags', []),
blur_tags = this.settings.get('directory.blur-tags', []);
if ( bad_tags.length || blur_tags.length ) {
for(const tag of tags) {
if ( tag?.name && bad_tags.includes(tag.name.toLowerCase()) ) {
bad_tag = true;
break;
if ( tag?.name ) {
const lname = tag.name.toLowerCase();
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;
if ( bad_tag )
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') )
should_hide = true;
else {
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;
if ( block_flags.size && el._ffz_flags ) {
for(const flag of el._ffz_flags)
if ( flag?.id && block_flags.has(flag.id) ) {
should_hide = true;
break;
}
}
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');
@ -642,4 +808,4 @@ export default class Directory extends Module {
this.router.navigate('user', { userName: user });
}
}
}

View file

@ -109,11 +109,10 @@ export default class ModView extends Module {
let state = node.memoizedState;
i = 0;
while(state != null && channel == null && i < 50) {
channel = state?.memoizedState?.current?.result?.data?.user ??
state?.memoizedState?.current?.previousData?.user;
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++;
}
node = node?.child;
@ -226,8 +225,9 @@ export default class ModView extends Module {
let channel = null, state = root?.return?.memoizedState, i = 0;
while(state != null && channel == null && i < 50 ) {
channel = state?.memoizedState?.current?.result?.data?.channel ??
state?.memoizedState?.current?.previousData?.channel;
state = state?.next;
channel = state?.memoizedState?.current?.previous?.result?.data?.channel;
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 {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
* 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;
cb: TFunc | null;
cb: ((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.cb = callback;

View file

@ -1136,6 +1136,12 @@ export class SourcedSet<T> {
/** Check to see if a specific source has any values. */
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. */
sourceIncludes(source: string, value: T) {
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_stream_ids: 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 _waiting_tags: Map<string, unknown>;
@ -60,6 +65,9 @@ export default class TwitchData extends Module {
this._waiting_stream_ids = 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._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
// ========================================================================

View file

@ -10,16 +10,10 @@ import type EmoteCard from "../modules/emote_card";
import type LinkCard from "../modules/link_card";
import type MainMenu from "../modules/main_menu";
import type TranslationUI from "../modules/translation_ui";
import type PubSub from "../pubsub";
import type SocketClient from "../socket";
import type StagingSelector from "../staging";
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 CSSTweaks from "./css-tweaks";
import type { NamespacedEvents } from "./events";
import type TwitchData from "./twitch-data";
/**
* 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. */
name: string;
name_i18n?: string;
/** Optional. A human-readable shortened name for the add-on, in English. */
short_name?: string;
short_name_i18n?: string;
/** The name of the add-on's author. */
author: string;
author_i18n?: string;
/** The name of the person or persons maintaining the add-on, if different than the author. */
maintainer?: string;
maintainer_i18n?: string;
/** A description of the add-on. This can be multiple lines and supports Markdown. */
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. */
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. */
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. */
created: Date;
@ -86,6 +88,9 @@ export type AddonInfo = {
/** List of FrankerFaceZ flavors ("main", "clips", "player") that this add-on supports. */
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()
@ -125,6 +130,15 @@ export type ExtractEach<T, Rest> =
: 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;
@ -229,15 +243,13 @@ export interface PubSubCommands {
};
// TODO: Move this event into addons.
type AddonEvent = {
'addon:fully-unload': [addon_id: string]
export interface ExperimentTypeMap {
never: unknown;
};
export interface ModuleEventMap {
export interface ModuleEventMap { };
};
export interface ModuleMap {
'chat': Chat;
@ -252,14 +264,13 @@ export interface ModuleMap {
'link_card': LinkCard;
'main_menu': MainMenu;
'site.apollo': Apollo;
'site.css_tweaks': CSSTweaks;
'site.web_munch': WebMunch;
'socket': SocketClient;
'translation_ui': TranslationUI;
};
export type KnownEvents = AddonEvent & UnionToIntersection<{
export type KnownEvents = UnionToIntersection<{
[K in keyof ModuleEventMap]: NamespacedEvents<K, ModuleEventMap[K]>
}[keyof ModuleEventMap]>;

View file

@ -183,6 +183,20 @@ textarea.ffz-input {
font-weight: bold;
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 {
max-height: 30rem;
}
}