mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 12:55:55 +00:00
4.61.0
* 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:
parent
31e7ce4ac5
commit
18491b0873
25 changed files with 846 additions and 208 deletions
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
8
src/modules/chat/types.ts
Normal file
8
src/modules/chat/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
|
||||
// ============================================================================
|
||||
// Badges
|
||||
// ============================================================================
|
||||
|
||||
export type BadgeAssignment = {
|
||||
id: string;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 )
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
@ -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">» </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>
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
})))
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
|||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
13
src/utilities/data/stream-flags.gql
Normal file
13
src/utilities/data/stream-flags.gql
Normal file
|
@ -0,0 +1,13 @@
|
|||
query FFZ_StreamFlags($ids: [ID!], $logins: [String!]) {
|
||||
users(ids: $ids, logins: $logins) {
|
||||
id
|
||||
login
|
||||
stream {
|
||||
id
|
||||
contentClassificationLabels {
|
||||
id
|
||||
localizedName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
// ========================================================================
|
||||
|
|
|
@ -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]>;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue