mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-07-25 12:08:30 +00:00
4.20.69
Nice. * Changed: Warn users that they have `Show Mod Icons` disabled within [Chat > Actions](~chat.actions). * Changed: Blocked Badges, Highlight Badges, and Blocked Types within [Chat > Filtering](~chat.filtering) no longer have a default item. This will hopefully minimize user confusion. * Changed: Blocked Badges also has a new description telling users that it isn't for hiding badges, with a link to the correct place to change badge visibility. * Changed: Remove the New Link Tokenization experiment, making it enabled for all users. * Changed: When navigating within the FFZ Control Center in a pop-out window, update the URL so that it can be shared to link to a specific settings page. * Changed: Disable the websocket connection for users in the API Links experiment to reduce load on the socket cluster. * Fixed: Bug with the FFZ Control Center failing to load if experiments haven't been populated correctly. * Fixed: Badge Visibility not being populated when opening the FFZ Control Center on a page without chat. * API Added: `<markdown />` now supports a link syntax for navigating to a new section of the FFZ Control Center. * API Fixed: Better tokenization for settings paths. Brackets can now be used safely in embedded JSON. * API Fixed: `deep_equals` and `shallow_object_equals` returning false when objects were otherwise equal but had keys in a different order.
This commit is contained in:
parent
f5135ad291
commit
77d6cf56d2
27 changed files with 365 additions and 96 deletions
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "frankerfacez",
|
"name": "frankerfacez",
|
||||||
"author": "Dan Salvato LLC",
|
"author": "Dan Salvato LLC",
|
||||||
"version": "4.20.68",
|
"version": "4.20.69",
|
||||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -58,18 +58,17 @@ export default class ExperimentManager extends Module {
|
||||||
getExtraTerms: () => {
|
getExtraTerms: () => {
|
||||||
const values = [];
|
const values = [];
|
||||||
|
|
||||||
for(const [key,val] of Object.entries(this.experiments)) {
|
for(const exps of [this.experiments, this.getTwitchExperiments()]) {
|
||||||
values.push(key);
|
if ( ! exps )
|
||||||
if ( val.name )
|
continue;
|
||||||
values.push(val.name);
|
|
||||||
if ( val.description )
|
|
||||||
values.push(val.description);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const [key, val] of Object.entries(this.getTwitchExperiments())) {
|
for(const [key, val] of Object.entries(exps)) {
|
||||||
values.push(key);
|
values.push(key);
|
||||||
if ( val.name )
|
if ( val.name )
|
||||||
values.push(val.name);
|
values.push(val.name);
|
||||||
|
if ( val.description )
|
||||||
|
values.push(val.description);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return values;
|
return values;
|
||||||
|
|
|
@ -1,12 +1,4 @@
|
||||||
{
|
{
|
||||||
"new_links": {
|
|
||||||
"name": "New Link Tokenization",
|
|
||||||
"description": "Update to Twitch's latest link regex. Experiment while this is checked for bugs.",
|
|
||||||
"groups": [
|
|
||||||
{"value": true, "weight": 100},
|
|
||||||
{"value": false, "weight": 0}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"api_load": {
|
"api_load": {
|
||||||
"name": "New API Stress Testing",
|
"name": "New API Stress Testing",
|
||||||
"description": "Send duplicate requests to the new API server for load testing.",
|
"description": "Send duplicate requests to the new API server for load testing.",
|
||||||
|
|
|
@ -242,7 +242,7 @@ export default class Badges extends Module {
|
||||||
path: 'Chat > Badges >> tabs ~> Visibility',
|
path: 'Chat > Badges >> tabs ~> Visibility',
|
||||||
title: 'Visibility',
|
title: 'Visibility',
|
||||||
component: 'badge-visibility',
|
component: 'badge-visibility',
|
||||||
data: () => this.getSettingsBadges(true)
|
getBadges: cb => this.getSettingsBadges(true, cb)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -276,7 +276,7 @@ export default class Badges extends Module {
|
||||||
this.handleClick = this.handleClick.bind(this);
|
this.handleClick = this.handleClick.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSettingsBadges(include_addons) {
|
getSettingsBadges(include_addons, callback) {
|
||||||
const twitch = [],
|
const twitch = [],
|
||||||
owl = [],
|
owl = [],
|
||||||
tcon = [],
|
tcon = [],
|
||||||
|
@ -284,6 +284,16 @@ export default class Badges extends Module {
|
||||||
ffz = [],
|
ffz = [],
|
||||||
addon = [];
|
addon = [];
|
||||||
|
|
||||||
|
const twitch_keys = Object.keys(this.twitch_badges);
|
||||||
|
if ( ! twitch_keys.length && callback ) {
|
||||||
|
const td = this.resolve('site.twitch_data');
|
||||||
|
if ( td )
|
||||||
|
td.getBadges().then(data => {
|
||||||
|
this.updateTwitchBadges(data);
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for(const key in this.twitch_badges)
|
for(const key in this.twitch_badges)
|
||||||
if ( has(this.twitch_badges, key) ) {
|
if ( has(this.twitch_badges, key) ) {
|
||||||
const badge = this.twitch_badges[key],
|
const badge = this.twitch_badges[key],
|
||||||
|
@ -334,6 +344,9 @@ export default class Badges extends Module {
|
||||||
const badge = this.badges[key],
|
const badge = this.badges[key],
|
||||||
image = badge.urls ? (badge.urls[2] || badge.urls[1]) : badge.image;
|
image = badge.urls ? (badge.urls[2] || badge.urls[1]) : badge.image;
|
||||||
|
|
||||||
|
if ( badge.no_visibility )
|
||||||
|
continue;
|
||||||
|
|
||||||
(badge.addon ? addon : ffz).push({
|
(badge.addon ? addon : ffz).push({
|
||||||
id: key,
|
id: key,
|
||||||
provider: 'ffz',
|
provider: 'ffz',
|
||||||
|
@ -1070,6 +1083,9 @@ export function fixBadgeData(badge) {
|
||||||
return badge;
|
return badge;
|
||||||
|
|
||||||
// Click Behavior
|
// Click Behavior
|
||||||
|
if ( ! badge.clickAction && badge.onClickAction )
|
||||||
|
badge.clickAction = badge.onClickAction;
|
||||||
|
|
||||||
if ( badge.clickAction === 'VISIT_URL' && badge.clickURL )
|
if ( badge.clickAction === 'VISIT_URL' && badge.clickURL )
|
||||||
badge.click_url = badge.clickURL;
|
badge.click_url = badge.clickURL;
|
||||||
|
|
||||||
|
|
|
@ -500,7 +500,7 @@ export default class Chat extends Module {
|
||||||
type: 'array_merge',
|
type: 'array_merge',
|
||||||
always_inherit: true,
|
always_inherit: true,
|
||||||
ui: {
|
ui: {
|
||||||
path: 'Chat > Filtering >> Blocked Badges',
|
path: 'Chat > Filtering >> Blocked Badges @{"description": "**Note:** This section is for filtering messages out of chat from users with specific badges. If you wish to hide a badge, go to [Chat > Badges >> Visibility](~chat.badges.tabs.visibility)."}',
|
||||||
component: 'badge-highlighting',
|
component: 'badge-highlighting',
|
||||||
removable: true,
|
removable: true,
|
||||||
data: () => this.badges.getSettingsBadges()
|
data: () => this.badges.getSettingsBadges()
|
||||||
|
|
|
@ -12,8 +12,8 @@ import {CATEGORIES} from './emoji';
|
||||||
|
|
||||||
|
|
||||||
const EMOTE_CLASS = 'chat-image chat-line__message--emote',
|
const EMOTE_CLASS = 'chat-image chat-line__message--emote',
|
||||||
WHITESPACE = /^\s*$/,
|
//WHITESPACE = /^\s*$/,
|
||||||
LINK_REGEX = /([^\w@#%\-+=:~])?((?:(https?:\/\/)?(?:[\w@#%\-+=:~]+\.)+[a-z]{2,6}(?:\/[\w./@#%&()\-+=:?~]*)?))([^\w./@#%&()\-+=:?~]|\s|$)/g,
|
//LINK_REGEX = /([^\w@#%\-+=:~])?((?:(https?:\/\/)?(?:[\w@#%\-+=:~]+\.)+[a-z]{2,6}(?:\/[\w./@#%&()\-+=:?~]*)?))([^\w./@#%&()\-+=:?~]|\s|$)/g,
|
||||||
NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*)?))/g,
|
NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*)?))/g,
|
||||||
//MENTION_REGEX = /([^\w@#%\-+=:~])?(@([^\u0000-\u007F]+|\w+)+)([^\w./@#%&()\-+=:?~]|\s|$)/g; // eslint-disable-line no-control-regex
|
//MENTION_REGEX = /([^\w@#%\-+=:~])?(@([^\u0000-\u007F]+|\w+)+)([^\w./@#%&()\-+=:?~]|\s|$)/g; // eslint-disable-line no-control-regex
|
||||||
MENTION_REGEX = /^(['"*([{<\\/]*)(@)((?:[^\u0000-\u007F]|[\w-])+)(?:\b|$)/; // eslint-disable-line no-control-regex
|
MENTION_REGEX = /^(['"*([{<\\/]*)(@)((?:[^\u0000-\u007F]|[\w-])+)(?:\b|$)/; // eslint-disable-line no-control-regex
|
||||||
|
@ -148,7 +148,7 @@ export const Links = {
|
||||||
if ( ! tokens || ! tokens.length )
|
if ( ! tokens || ! tokens.length )
|
||||||
return tokens;
|
return tokens;
|
||||||
|
|
||||||
const use_new = this.experiments.getAssignment('new_links');
|
//const use_new = this.experiments.getAssignment('new_links');
|
||||||
|
|
||||||
const out = [];
|
const out = [];
|
||||||
for(const token of tokens) {
|
for(const token of tokens) {
|
||||||
|
@ -157,28 +157,28 @@ export const Links = {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
LINK_REGEX.lastIndex = 0;
|
//LINK_REGEX.lastIndex = 0;
|
||||||
NEW_LINK_REGEX.lastIndex = 0;
|
NEW_LINK_REGEX.lastIndex = 0;
|
||||||
const text = token.text;
|
const text = token.text;
|
||||||
let idx = 0, match;
|
let idx = 0, match;
|
||||||
|
|
||||||
if ( use_new ) {
|
//if ( use_new ) {
|
||||||
while((match = NEW_LINK_REGEX.exec(text))) {
|
while((match = NEW_LINK_REGEX.exec(text))) {
|
||||||
const nix = match.index;
|
const nix = match.index;
|
||||||
if ( idx !== nix )
|
if ( idx !== nix )
|
||||||
out.push({type: 'text', text: text.slice(idx, nix)});
|
out.push({type: 'text', text: text.slice(idx, nix)});
|
||||||
|
|
||||||
out.push({
|
out.push({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
url: `${match[1] ? '' : 'https://'}${match[0]}`,
|
url: `${match[1] ? '' : 'https://'}${match[0]}`,
|
||||||
is_mail: false,
|
is_mail: false,
|
||||||
text: match[0]
|
text: match[0]
|
||||||
});
|
});
|
||||||
|
|
||||||
idx = nix + match[0].length;
|
idx = nix + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
/*} else {
|
||||||
while((match = LINK_REGEX.exec(text))) {
|
while((match = LINK_REGEX.exec(text))) {
|
||||||
const nix = match.index + (match[1] ? match[1].length : 0);
|
const nix = match.index + (match[1] ? match[1].length : 0);
|
||||||
if ( idx !== nix )
|
if ( idx !== nix )
|
||||||
|
@ -195,7 +195,7 @@ export const Links = {
|
||||||
|
|
||||||
idx = nix + match[2].length;
|
idx = nix + match[2].length;
|
||||||
}
|
}
|
||||||
}
|
}*/
|
||||||
|
|
||||||
if ( idx < text.length )
|
if ( idx < text.length )
|
||||||
out.push({type: 'text', text: text.slice(idx)});
|
out.push({type: 'text', text: text.slice(idx)});
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
:key="addon.id"
|
:key="addon.id"
|
||||||
:addon="addon"
|
:addon="addon"
|
||||||
:item="item"
|
:item="item"
|
||||||
|
:context="context"
|
||||||
@navigate="navigate"
|
@navigate="navigate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -124,7 +124,7 @@
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ['id', 'addon', 'item'],
|
props: ['id', 'addon', 'item', 'context'],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -40,7 +40,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
default_term: {
|
default_term: {
|
||||||
v: 'broadcaster',
|
v: '',
|
||||||
c: '',
|
c: '',
|
||||||
remove: false
|
remove: false
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,10 @@
|
||||||
:src="current.image"
|
:src="current.image"
|
||||||
class="ffz--badge-term-image"
|
class="ffz--badge-term-image"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="ffz--badge-term-image"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-flex-grow-1 tw-mg-r-05">
|
<div class="tw-flex-grow-1 tw-mg-r-05">
|
||||||
<h4 v-if="! editing && ! current" class="ffz-monospace">
|
<h4 v-if="! editing && ! current" class="ffz-monospace">
|
||||||
|
@ -20,6 +24,9 @@
|
||||||
v-model="edit_data.v"
|
v-model="edit_data.v"
|
||||||
class="tw-block tw-full-width tw-border-radius-medium tw-font-size-6 tw-full-width ffz-select tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
|
class="tw-block tw-full-width tw-border-radius-medium tw-font-size-6 tw-full-width ffz-select tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
|
||||||
>
|
>
|
||||||
|
<option v-if="adding" value="">
|
||||||
|
{{ t('setting.terms.please-select', 'Please select an option.') }}
|
||||||
|
</option>
|
||||||
<optgroup
|
<optgroup
|
||||||
v-for="section in badges"
|
v-for="section in badges"
|
||||||
:key="section.title"
|
:key="section.title"
|
||||||
|
@ -69,14 +76,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="adding" class="tw-flex-shrink-0">
|
<div v-if="adding" class="tw-flex-shrink-0">
|
||||||
<button class="tw-button" @click="save">
|
<button
|
||||||
|
class="tw-button"
|
||||||
|
:class="! valid && 'tw-button--disabled'"
|
||||||
|
:disabled="! valid"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
<span class="tw-button__text">
|
<span class="tw-button__text">
|
||||||
{{ t('setting.terms.add-term', 'Add') }}
|
{{ t('setting.terms.add-term', 'Add') }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="editing" class="tw-flex-shrink-0">
|
<div v-else-if="editing" class="tw-flex-shrink-0">
|
||||||
<button class="tw-button tw-button--text tw-tooltip__container" @click="save">
|
<button
|
||||||
|
class="tw-button tw-button--text tw-tooltip__container"
|
||||||
|
:class="! valid && 'tw-button--disabled'"
|
||||||
|
:disabled="! valid"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
<span class="tw-button__text ffz-i-floppy" />
|
<span class="tw-button__text ffz-i-floppy" />
|
||||||
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
|
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
|
||||||
{{ t('setting.save', 'Save') }}
|
{{ t('setting.save', 'Save') }}
|
||||||
|
@ -163,6 +180,10 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
valid() {
|
||||||
|
return this.display.v && this.display.v !== '';
|
||||||
|
},
|
||||||
|
|
||||||
display() {
|
display() {
|
||||||
return this.editing ? this.edit_data : this.term;
|
return this.editing ? this.edit_data : this.term;
|
||||||
},
|
},
|
||||||
|
@ -207,7 +228,8 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
this.$emit('save', this.edit_data);
|
if ( this.valid )
|
||||||
|
this.$emit('save', this.edit_data);
|
||||||
this.cancel();
|
this.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
v-for="sec in data"
|
v-for="sec in badges"
|
||||||
:key="sec.title"
|
:key="sec.title"
|
||||||
class="ffz--menu-container tw-border-t"
|
class="ffz--menu-container tw-border-t"
|
||||||
>
|
>
|
||||||
|
@ -58,7 +58,7 @@
|
||||||
</header>
|
</header>
|
||||||
<ul class="tw-flex tw-flex-wrap tw-align-content-start">
|
<ul class="tw-flex tw-flex-wrap tw-align-content-start">
|
||||||
<li
|
<li
|
||||||
v-for="i in sort(sec.badges)"
|
v-for="i in sec.badges"
|
||||||
:key="i.id"
|
:key="i.id"
|
||||||
:class="{default: badgeDefault(i.id)}"
|
:class="{default: badgeDefault(i.id)}"
|
||||||
class="ffz--badge-info tw-pd-y-1 tw-pd-r-1 tw-flex ffz-checkbox"
|
class="ffz--badge-info tw-pd-y-1 tw-pd-r-1 tw-flex ffz-checkbox"
|
||||||
|
@ -115,11 +115,41 @@
|
||||||
import SettingMixin from '../setting-mixin';
|
import SettingMixin from '../setting-mixin';
|
||||||
import {has} from 'utilities/object';
|
import {has} from 'utilities/object';
|
||||||
|
|
||||||
|
function sortBadges(items) {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const an = a.name.toLowerCase(),
|
||||||
|
bn = b.name.toLowerCase();
|
||||||
|
|
||||||
|
if ( an < bn ) return -1;
|
||||||
|
if ( an > bn ) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [SettingMixin],
|
mixins: [SettingMixin],
|
||||||
props: ['item', 'context'],
|
props: ['item', 'context'],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
badges: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.updateBadges();
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
updateBadges() {
|
||||||
|
const badges = this.item.getBadges(() => this.updateBadges());
|
||||||
|
for(const section of badges) {
|
||||||
|
section.badges = sortBadges(section.badges);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.badges = badges;
|
||||||
|
},
|
||||||
|
|
||||||
badgeChecked(id) {
|
badgeChecked(id) {
|
||||||
return ! this.value[id];
|
return ! this.value[id];
|
||||||
},
|
},
|
||||||
|
@ -143,17 +173,6 @@ export default {
|
||||||
this.clear();
|
this.clear();
|
||||||
else
|
else
|
||||||
this.set(val);
|
this.set(val);
|
||||||
},
|
|
||||||
|
|
||||||
sort(items) {
|
|
||||||
return items.sort((a, b) => {
|
|
||||||
const an = a.name.toLowerCase(),
|
|
||||||
bn = b.name.toLowerCase();
|
|
||||||
|
|
||||||
if ( an < bn ) return -1;
|
|
||||||
if ( an > bn ) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,9 @@
|
||||||
v-model="edit_data.v"
|
v-model="edit_data.v"
|
||||||
class="tw-block tw-full-width tw-border-radius-medium tw-font-size-6 tw-full-width ffz-select tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
|
class="tw-block tw-full-width tw-border-radius-medium tw-font-size-6 tw-full-width ffz-select tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
|
||||||
>
|
>
|
||||||
|
<option v-if="adding" value="">
|
||||||
|
{{ t('setting.terms.please-select', 'Please select an option.') }}
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
v-for="type in types"
|
v-for="type in types"
|
||||||
:key="type"
|
:key="type"
|
||||||
|
@ -20,7 +23,12 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="adding" class="tw-flex-shrink-0">
|
<div v-if="adding" class="tw-flex-shrink-0">
|
||||||
<button class="tw-button" @click="save">
|
<button
|
||||||
|
class="tw-button"
|
||||||
|
:class="! valid && 'tw-button--disabled'"
|
||||||
|
:disabled="! valid"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
<span class="tw-button__text">
|
<span class="tw-button__text">
|
||||||
{{ t('setting.terms.add-term', 'Add') }}
|
{{ t('setting.terms.add-term', 'Add') }}
|
||||||
</span>
|
</span>
|
||||||
|
@ -94,6 +102,10 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
valid() {
|
||||||
|
return this.display.v && this.display.v !== '';
|
||||||
|
},
|
||||||
|
|
||||||
display() {
|
display() {
|
||||||
return this.editing ? this.edit_data : this.term;
|
return this.editing ? this.edit_data : this.term;
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
default_type: {
|
default_type: {
|
||||||
v: 'Hosted'
|
v: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -8,6 +8,14 @@
|
||||||
{{ t('setting.warn-inheritence', 'These values are being overridden by another profile and may not take effect.') }}
|
{{ t('setting.warn-inheritence', 'These values are being overridden by another profile and may not take effect.') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="(item.warn_icons || (has_icons && item.warn_icons !== false)) && context.mod_icons === false"
|
||||||
|
class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1"
|
||||||
|
>
|
||||||
|
<span class="ffz-i-info" />
|
||||||
|
{{ t('setting.actions.warn-hidden', 'You currently have Mod Icons turned off in your Twitch chat settings, so some actions might be hidden as a result. Use the settings menu in chat to toggle them.') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tw-pd-b-1 tw-border-b tw-mg-b-1">
|
<div class="tw-pd-b-1 tw-border-b tw-mg-b-1">
|
||||||
<div class="tw-flex tw-flex-wrap tw-align-items-center ffz--inline">
|
<div class="tw-flex tw-flex-wrap tw-align-items-center ffz--inline">
|
||||||
{{ t('setting.actions.preview', 'Preview:') }}
|
{{ t('setting.actions.preview', 'Preview:') }}
|
||||||
|
|
|
@ -193,14 +193,24 @@ export default {
|
||||||
|
|
||||||
this.markSeen(item);
|
this.markSeen(item);
|
||||||
|
|
||||||
|
if ( item.redirect )
|
||||||
|
return this.navigate(Array.isArray(item.redirect) ? item.redirect : item.redirect.split(/\./g));
|
||||||
|
|
||||||
this.currentItem = item;
|
this.currentItem = item;
|
||||||
this.restoredItem = true;
|
this.restoredItem = true;
|
||||||
|
|
||||||
|
let url;
|
||||||
|
if ( this.exclusive ) {
|
||||||
|
url = new URL(location.href);
|
||||||
|
url.searchParams.set('ffz-settings', item.full_key);
|
||||||
|
url = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.history.replaceState({
|
window.history.replaceState({
|
||||||
...window.history.state,
|
...window.history.state,
|
||||||
ffzcc: item.full_key
|
ffzcc: item.full_key
|
||||||
}, document.title)
|
}, document.title, url);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
/* no-op */
|
/* no-op */
|
||||||
}
|
}
|
||||||
|
@ -251,13 +261,34 @@ export default {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
while(item && item.page)
|
const tabs = [];
|
||||||
|
|
||||||
|
while(item && item.page) {
|
||||||
|
if ( item.tab && item.parent?.tabs )
|
||||||
|
tabs.push([item.parent, item.parent.tabs.indexOf(item)]);
|
||||||
|
|
||||||
item = item.parent;
|
item = item.parent;
|
||||||
|
}
|
||||||
|
|
||||||
if ( ! item )
|
if ( ! item )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.changeItem(item);
|
this.changeItem(item);
|
||||||
|
|
||||||
|
// Asynchronously walk down the tab tree, so that
|
||||||
|
// we can switch every tab correctly.
|
||||||
|
if ( tabs.length ) {
|
||||||
|
const bits = () => {
|
||||||
|
const latest = tabs.pop();
|
||||||
|
if ( latest?.[0]?._component )
|
||||||
|
latest[0]._component.select(latest[1]);
|
||||||
|
|
||||||
|
if ( tabs.length )
|
||||||
|
this.$nextTick(bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$nextTick(bits);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template lang="html">
|
<template lang="html">
|
||||||
<div class="ffz--provider tw-pd-t-05">
|
<div class="ffz--provider tw-pd-t-05">
|
||||||
<div v-if="not_www" class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1">
|
<div v-if="not_www" class="ffz--notice tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1">
|
||||||
<h3 class="ffz-i-attention">
|
<h3 class="ffz-i-attention">
|
||||||
{{ t('setting.provider.warn-domain.title', 'You\'re far from home!') }}
|
{{ t('setting.provider.warn-domain.title', 'You\'re far from home!') }}
|
||||||
</h3>
|
</h3>
|
||||||
|
@ -9,12 +9,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1">
|
<div class="ffz--notice tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1">
|
||||||
<h3 class="ffz-i-attention">
|
<h3 class="ffz-i-attention">
|
||||||
{{ t('setting.provider.warn.title', 'Be careful!') }}
|
{{ t('setting.provider.warn.title', 'Be careful!') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<markdown :source="t('setting.provider.warn.desc', 'Please close any other Twitch tabs before using this tool. It is **recommended to create a backup** before changing your provider, in case anything happens.')" />
|
<markdown :source="t('setting.provider.warn.desc', 'Please close any other Twitch tabs before using this tool. It is **recommended to [create a backup](~data_management.backup_and_restore)** before changing your provider, in case anything happens.')" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -216,6 +216,15 @@ export default class MainMenu extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
mdNavigate(thing) {
|
||||||
|
const path = thing?.dataset?.settingsLink;
|
||||||
|
if ( ! path )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.requestPage(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
updateContext(context) {
|
updateContext(context) {
|
||||||
if ( ! context )
|
if ( ! context )
|
||||||
context = this._context;
|
context = this._context;
|
||||||
|
@ -732,6 +741,7 @@ export default class MainMenu extends Module {
|
||||||
can_proxy: context._context.can_proxy,
|
can_proxy: context._context.can_proxy,
|
||||||
proxied: context._context.proxied,
|
proxied: context._context.proxied,
|
||||||
has_update: this.has_update,
|
has_update: this.has_update,
|
||||||
|
mod_icons: context.get('context.chat.showModIcons'),
|
||||||
|
|
||||||
setProxied: val => {
|
setProxied: val => {
|
||||||
this.use_context = val;
|
this.use_context = val;
|
||||||
|
@ -828,8 +838,9 @@ export default class MainMenu extends Module {
|
||||||
const profiles = context.manager.__profiles,
|
const profiles = context.manager.__profiles,
|
||||||
ids = this.profiles = context.__profiles.map(profile => profile.id);
|
ids = this.profiles = context.__profiles.map(profile => profile.id);
|
||||||
|
|
||||||
this.proxied = this.context.proxied;
|
_c.proxied = this.context.proxied;
|
||||||
this.can_proxy = this.context.can_proxy;
|
_c.can_proxy = this.context.can_proxy;
|
||||||
|
_c.mod_icons = context.get('context.chat.showModIcons');
|
||||||
|
|
||||||
for(let i=0; i < profiles.length; i++) {
|
for(let i=0; i < profiles.length; i++) {
|
||||||
const id = profiles[i].id,
|
const id = profiles[i].id,
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
import {deep_equals, has, debounce, deep_copy} from 'utilities/object';
|
import {deep_equals, has, debounce, deep_copy} from 'utilities/object';
|
||||||
|
import {parse as new_parse} from 'utilities/path-parser';
|
||||||
|
|
||||||
import SettingsProfile from './profile';
|
import SettingsProfile from './profile';
|
||||||
import SettingsContext from './context';
|
import SettingsContext from './context';
|
||||||
|
@ -913,9 +914,14 @@ export default class SettingsManager extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const PATH_SPLITTER = /(?:^|\s*([~>]+))\s*([^~>@]+)\s*(?:@([^~>]+))?/g;
|
|
||||||
|
|
||||||
export function parse_path(path) {
|
export function parse_path(path) {
|
||||||
|
return new_parse(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*const PATH_SPLITTER = /(?:^|\s*([~>]+))\s*([^~>@]+)\s*(?:@([^~>]+))?/g;
|
||||||
|
|
||||||
|
export function old_parse_path(path) {
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
|
@ -928,14 +934,20 @@ export function parse_path(path) {
|
||||||
|
|
||||||
opts = { key, title, page, tab };
|
opts = { key, title, page, tab };
|
||||||
|
|
||||||
if ( options )
|
if ( options ) {
|
||||||
Object.assign(opts, JSON.parse(options));
|
try {
|
||||||
|
Object.assign(opts, JSON.parse(options));
|
||||||
|
} catch(err) {
|
||||||
|
console.warn('Matched segment:', options);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tokens.push(opts);
|
tokens.push(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
|
|
||||||
export function format_path_tokens(tokens) {
|
export function format_path_tokens(tokens) {
|
||||||
|
|
|
@ -29,7 +29,7 @@ export default class BitsButton extends Module {
|
||||||
ui: {
|
ui: {
|
||||||
path: 'Appearance > Layout >> Top Navigation',
|
path: 'Appearance > Layout >> Top Navigation',
|
||||||
title: 'Show the Get Bits button.',
|
title: 'Show the Get Bits button.',
|
||||||
description: 'By default, this inherits its value from Chat > Bits and Cheering > Display Bits',
|
description: 'By default, this inherits its value from [Chat > Bits and Cheering > Display Bits](~chat.bits_and_cheering)',
|
||||||
component: 'setting-check-box'
|
component: 'setting-check-box'
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -298,7 +298,7 @@ export default class ChatHook extends Module {
|
||||||
ui: {
|
ui: {
|
||||||
path: 'Chat > Appearance >> Replies',
|
path: 'Chat > Appearance >> Replies',
|
||||||
title: 'Style',
|
title: 'Style',
|
||||||
description: `Twitch's default style adds a floating button to the right and displays a notice above messages that are replies. FrankerFaceZ uses an In-Line Chat Action (that can be removed in Chat > Actions > In-Line) and uses an in-line mention to denote replies.`,
|
description: `Twitch's default style adds a floating button to the right and displays a notice above messages that are replies. FrankerFaceZ uses an In-Line Chat Action (that can be removed in [Chat > Actions > In-Line](~chat.actions.in_line)) and uses an in-line mention to denote replies.`,
|
||||||
component: 'setting-select-box',
|
component: 'setting-select-box',
|
||||||
data: [
|
data: [
|
||||||
{value: 0, title: 'Disabled'},
|
{value: 0, title: 'Disabled'},
|
||||||
|
@ -469,7 +469,7 @@ export default class ChatHook extends Module {
|
||||||
ui: {
|
ui: {
|
||||||
path: 'Chat > Appearance >> Community',
|
path: 'Chat > Appearance >> Community',
|
||||||
title: 'Display Leaderboard',
|
title: 'Display Leaderboard',
|
||||||
description: 'The leaderboard shows the top cheerers and sub gifters in a channel.\n\nBy default due to a previous implementation, this inherits its value from Chat > Bits and Cheering > Display Bits.',
|
description: 'The leaderboard shows the top cheerers and sub gifters in a channel.\n\nBy default due to a previous implementation, this inherits its value from [Chat > Bits and Cheering > Display Bits](~chat.bits_and_cheering).',
|
||||||
component: 'setting-check-box'
|
component: 'setting-check-box'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -25,6 +25,7 @@ export default class SocketClient extends Module {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
|
||||||
this.inject('settings');
|
this.inject('settings');
|
||||||
|
this.inject('experiments');
|
||||||
|
|
||||||
this.settings.addUI('socket.info', {
|
this.settings.addUI('socket.info', {
|
||||||
path: 'Debugging > Socket >> Info @{"sort": -1000}',
|
path: 'Debugging > Socket >> Info @{"sort": -1000}',
|
||||||
|
@ -99,7 +100,14 @@ export default class SocketClient extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onEnable() { this.connect() }
|
onEnable() {
|
||||||
|
// For now, stop connecting to the sockets for people using the
|
||||||
|
// API links experiment.
|
||||||
|
if ( this.experiments.getAssignment('api_links') )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
onDisable() { this.disconnect() }
|
onDisable() { this.disconnect() }
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,99 @@
|
||||||
|
|
||||||
import MD from 'markdown-it';
|
import MD from 'markdown-it';
|
||||||
import MILA from 'markdown-it-link-attributes';
|
import MILA from 'markdown-it-link-attributes';
|
||||||
|
import {parse as parse_path} from 'utilities/path-parser';
|
||||||
|
|
||||||
|
let _md;
|
||||||
|
|
||||||
|
function getMD() {
|
||||||
|
if ( ! _md ) {
|
||||||
|
const md = _md = new MD({
|
||||||
|
html: false,
|
||||||
|
linkify: true
|
||||||
|
});
|
||||||
|
|
||||||
|
md.use(SettingsLinks);
|
||||||
|
md.use(MILA, {
|
||||||
|
attrs: {
|
||||||
|
class: 'ffz-tooltip',
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener',
|
||||||
|
'data-tooltip-type': 'link'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return _md;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function SettingsLinks(md) {
|
||||||
|
const default_render = md.renderer.rules.link_open || this.defaultRender;
|
||||||
|
|
||||||
|
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
||||||
|
const token = tokens[idx];
|
||||||
|
if ( token && token.type === 'link_open' && Array.isArray(token.attrs) ) {
|
||||||
|
let href;
|
||||||
|
for(const attr of token.attrs) {
|
||||||
|
if ( attr[0] === 'href' ) {
|
||||||
|
href = attr[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( href.startsWith('~') ) {
|
||||||
|
let path;
|
||||||
|
|
||||||
|
if ( href === '~' ) {
|
||||||
|
// We don't have a path, make one from the bits.
|
||||||
|
let i = idx + 1;
|
||||||
|
let bits = [];
|
||||||
|
|
||||||
|
while(i < tokens.length) {
|
||||||
|
const tok = tokens[i],
|
||||||
|
type = tok?.type;
|
||||||
|
if ( type === 'text' )
|
||||||
|
bits.push(tok);
|
||||||
|
else if ( type === 'link_close' )
|
||||||
|
break;
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
bits = bits.map(x => x.content).join('');
|
||||||
|
const toks = parse_path(bits);
|
||||||
|
path = toks.map(x => x.key).join('.');
|
||||||
|
} else
|
||||||
|
path = href.slice(1);
|
||||||
|
|
||||||
|
if ( path && path.length ) {
|
||||||
|
for(const attr of token.attrs) {
|
||||||
|
if ( attr[0] === 'class' ) {
|
||||||
|
attr[1] = attr[1].replace(/ffz-tooltip/g, '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token.attrs.push([
|
||||||
|
'data-settings-link',
|
||||||
|
path
|
||||||
|
]);
|
||||||
|
token.attrs.push([
|
||||||
|
'onclick',
|
||||||
|
'FrankerFaceZ.get().resolve("main_menu").mdNavigate(this);return false'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return default_render(tokens, idx, options, env, self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsLinks.defaultRender = function(tokens, idx, options, env, self) {
|
||||||
|
return self.renderToken(tokens, idx, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
@ -15,21 +108,7 @@ export default {
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
md() {
|
md() {
|
||||||
const md = new MD({
|
return getMD();
|
||||||
html: false,
|
|
||||||
linkify: true
|
|
||||||
});
|
|
||||||
|
|
||||||
md.use(MILA, {
|
|
||||||
attrs: {
|
|
||||||
class: 'ffz-tooltip',
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener',
|
|
||||||
'data-tooltip-type': 'link'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return md;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
output() {
|
output() {
|
||||||
|
|
|
@ -82,10 +82,20 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
if ( ! this.item._component )
|
||||||
|
this.item._component = this;
|
||||||
|
},
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.markSeen()
|
this.markSeen()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
if ( this.item._component === this )
|
||||||
|
this.item._component = null;
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
focus() {
|
focus() {
|
||||||
this.$el.querySelector('header').focus();
|
this.$el.querySelector('header').focus();
|
||||||
|
|
13
src/utilities/data/global-badges.gql
Normal file
13
src/utilities/data/global-badges.gql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
query FFZ_GlobalBadges {
|
||||||
|
badges {
|
||||||
|
id
|
||||||
|
clickURL
|
||||||
|
onClickAction
|
||||||
|
title
|
||||||
|
setID
|
||||||
|
version
|
||||||
|
image1x: imageURL(size: NORMAL)
|
||||||
|
image2x: imageURL(size: DOUBLE)
|
||||||
|
image4x: imageURL(size: QUADRUPLE)
|
||||||
|
}
|
||||||
|
}
|
|
@ -197,7 +197,7 @@ export function deep_equals(object, other, ignore_undefined = false, seen, other
|
||||||
const source_keys = Object.keys(object),
|
const source_keys = Object.keys(object),
|
||||||
dest_keys = Object.keys(other);
|
dest_keys = Object.keys(other);
|
||||||
|
|
||||||
if ( ! ignore_undefined && ! array_equals(source_keys, dest_keys) )
|
if ( ! ignore_undefined && ! set_equals(new Set(source_keys), new Set(dest_keys)) )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
for(const key of source_keys)
|
for(const key of source_keys)
|
||||||
|
@ -216,10 +216,14 @@ export function deep_equals(object, other, ignore_undefined = false, seen, other
|
||||||
|
|
||||||
|
|
||||||
export function shallow_object_equals(a, b) {
|
export function shallow_object_equals(a, b) {
|
||||||
if ( typeof a !== 'object' || typeof b !== 'object' || ! array_equals(Object.keys(a), Object.keys(b)) )
|
if ( typeof a !== 'object' || typeof b !== 'object' )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
for(const key in a)
|
const keys = Object.keys(a);
|
||||||
|
if ( ! set_equals(new Set(keys), new Set(Object.keys(b))) )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for(const key of keys)
|
||||||
if ( a[key] !== b[key] )
|
if ( a[key] !== b[key] )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|
|
@ -158,6 +158,19 @@ export default class TwitchData extends Module {
|
||||||
return this._search;
|
return this._search;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Badges
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
async getBadges() {
|
||||||
|
const data = await this.queryApollo(
|
||||||
|
await import(/* webpackChunkName: 'queries' */ './data/global-badges.gql')
|
||||||
|
);
|
||||||
|
|
||||||
|
return get('data.badges', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Categories
|
// Categories
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
|
@ -18,10 +18,29 @@
|
||||||
.tw-display-inline { display: inline !important }
|
.tw-display-inline { display: inline !important }
|
||||||
.tw-width-auto { width: auto !important }
|
.tw-width-auto { width: auto !important }
|
||||||
|
|
||||||
|
.ffz--notice {
|
||||||
|
a {
|
||||||
|
color: var(--color-text-overlay-link);
|
||||||
|
|
||||||
|
&:hover, &:hover:focus {
|
||||||
|
color: var(--color-text-overlay-link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
color: var(--color-text-overlay-link-focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ffz-unmatched-item { opacity: 0.25 }
|
.ffz-unmatched-item { opacity: 0.25 }
|
||||||
.ffz-monospace { font-family: monospace }
|
.ffz-monospace { font-family: monospace }
|
||||||
.ffz-bottom-100 { bottom: 100% }
|
.ffz-bottom-100 { bottom: 100% }
|
||||||
|
|
||||||
|
.ffz--badge-term-image {
|
||||||
|
height: 3.6rem;
|
||||||
|
width: 3.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ffz--autocomplete {
|
.ffz--autocomplete {
|
||||||
.scrollable-area {
|
.scrollable-area {
|
||||||
max-height: 20rem;
|
max-height: 20rem;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue