1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
The Profile Update! Now, it's possible to create custom settings profiles. The important thing about profiles is that you can have different profiles run according to different rules. Want some settings to only apply on your dashboard? Use a Current Page rule set to Dashboard. Want your chat wider in theater mode? Create a profile for Theater Mode.

* Added: Profile Editor.
* Added: Ability to import and export specific profiles.
* Added: Ability to update profiles from JSON files for more advanced users.
* Fixed: Update `sortablejs` dependency to fix issue with sorting behavior.
* Fixed: Several issues in the settings profile system which never came up because custom profiles weren't available.
* Fixed: Hotkeys freezing chat when they shouldn't, up until the first time the mouse hovers over chat.
* API Added: `deep_equals(object, other, ignore_undefined = false)` method of `FrankerFaceZ.utilities.object` for comparing two objects, deeply.
* API Changed: `<setting-check-box />` controls can now display their indeterminate state.
* API Fixed: `deep_copy()` eating Promises and Functions.
This commit is contained in:
SirStendec 2019-06-13 22:56:50 -04:00
parent 734a73eb0e
commit c34b7e30e2
31 changed files with 1421 additions and 129 deletions

6
package-lock.json generated
View file

@ -8727,9 +8727,9 @@
}
},
"sortablejs": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.7.0.tgz",
"integrity": "sha1-gKKyNwq9Vo4c7IwnETHvMKkE+ig="
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.9.0.tgz",
"integrity": "sha512-Ot6bYJ6PoqPmpsqQYXjn1+RKrY2NWQvQt/o4jfd/UYwVWndyO5EPO8YHbnm5HIykf8ENsm4JUrdAvolPT86yYA=="
},
"source-list-map": {
"version": "2.0.0",

View file

@ -70,7 +70,7 @@
"raven-js": "^3.24.2",
"react": "^16.4.1",
"safe-regex": "^1.1.0",
"sortablejs": "^1.7.0",
"sortablejs": "^1.9.0",
"vue": "^2.5.16",
"vue-clickaway": "^2.2.2",
"vue-color": "^2.4.6",

Binary file not shown.

View file

@ -128,6 +128,8 @@
<glyph glyph-name="ellipsis-vert" unicode="&#xf142;" d="M214 154v-108q0-22-15-37t-38-16h-107q-23 0-38 16t-16 37v108q0 22 16 38t38 15h107q22 0 38-15t15-38z m0 285v-107q0-22-15-38t-38-15h-107q-23 0-38 15t-16 38v107q0 23 16 38t38 16h107q22 0 38-16t15-38z m0 286v-107q0-22-15-38t-38-16h-107q-23 0-38 16t-16 38v107q0 22 16 38t38 16h107q22 0 38-16t15-38z" horiz-adv-x="214.3" />
<glyph glyph-name="language" unicode="&#xf1ab;" d="M365 248q-1-1-7 1t-18 6l-11 5q-24 11-48 28-4 2-23 17t-21 16q-38-57-75-101-45-53-59-61-2-1-11-3t-10 0q3 3 46 52 12 13 48 64t43 66q10 17 29 55t20 43q-5 1-61-18-5-2-16-5t-19-5-10-3q-1-1-1-6t0-5q-3-5-18-8-12-4-26 0-10 2-15 11-3 4-3 13 3 1 13 3t17 3q32 9 59 18 55 20 56 20 6 1 24 11t25 12q5 1 12 4t8 3 3 0q2-7 0-18 0-2-7-16t-15-29-9-19q-14-28-43-73l35-16q7-3 42-18t38-15q2-1 5-14t3-18z m-114 272q1-9-3-16-6-13-28-21-16-7-33-7-15 2-27 15-8 8-10 23l0 1q2-1 11-3t15 0 32 9q20 7 31 8 9 0 12-9z m389-72l35-127-77 23z m-618-447l387 130v576l-387-130v-576z m692 177l57-17-101 367-56 17-120-299 57-18 25 62 118-36z m-280 537l319-103v212z m173-738l88-8-30-89-22 37q-73-46-154-60-32-7-51-7h-47q-44 0-111 22t-102 47q-5 4-5 9 0 5 3 8t7 3q2 0 10-5t17-9 12-6q40-21 89-34t87-14q54 0 94 8t87 28q9 4 17 9t19 11 16 9z m250 602v-602l-432 137q-8-3-209-71t-205-68q-8 0-10 7 0 1-1 2v602q2 5 2 5 3 3 11 6 60 20 84 28v214l311-110q1 0 90 31t176 60 90 30q11 0 11-12v-233z" horiz-adv-x="857.1" />
<glyph glyph-name="twitch" unicode="&#xf1e8;" d="M500 608v-242h-81v242h81z m222 0v-242h-81v242h81z m0-424l141 141v444h-666v-585h182v-121l121 121h222z m222 666v-565l-242-242h-182l-121-122h-121v122h-222v646l61 161h827z" horiz-adv-x="1000" />
<glyph glyph-name="bell-off" unicode="&#xf1f7;" d="M580-96q0 8-9 8-32 0-56 24t-24 57q0 9-9 9t-9-9q0-41 29-70t69-28q9 0 9 9z m-299 265l489 424q-23 49-74 82t-125 32q-51 0-94-17t-68-45-38-58-14-58q0-215-76-360z m755-105q0-29-21-50t-51-21h-250q0-59-42-101t-101-42-100 42-42 100l83 72h422q-92 105-126 256l61 55q35-199 167-311z m48 777l47-53q4-6 4-14t-6-12l-1045-905q-5-5-13-4t-12 6l-47 53q-4 6-4 14t6 12l104 90q-11 17-11 36 28 24 51 49t47 67 42 89 28 115 11 145q0 84 65 157t171 89q-4 10-4 21 0 23 16 38t37 16 38-16 16-38q0-11-4-21 69-10 122-46t82-88l234 202q5 5 13 4t12-6z" horiz-adv-x="1142.9" />

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -151,7 +151,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`
FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = {
major: 4, minor: 3, revision: 2,
major: 4, minor: 4, revision: 0,
commit: __git_commit__,
build: __webpack_hash__,
toString: () =>

View file

@ -1,52 +1,50 @@
<template lang="html">
<div class="ffz--widget ffz--filter-editor">
<div ref="list" class="ffz--rule-list">
<section v-for="(rule, idx) in rules" :key="`rule-${idx}`">
<div
class="ffz--rule tw-elevation-1 tw-c-background-base tw-border tw-mg-b-05 tw-pd-y-05 tw-pd-r-1 tw-flex tw-flex-nowrap tw-align-items-start"
tabindex="0"
>
<div class="tw-flex tw-flex-shrink-0 tw-align-items-center tw-handle tw-pd-x-05 tw-pd-y-1">
<span class="ffz-i-ellipsis-vert" />
</div>
<div class="tw-flex-shrink-0 tw-pd-y-05">
Channel
</div>
<div class="tw-mg-x-1 tw-flex tw-flex-grow-1">
<div class="tw-flex-shrink-0 tw-mg-r-1">
<select class="tw-select">
<option>is one of</option>
<option>is not one of</option>
</select>
</div>
<div class="tw-flex-grow-1">
<input
type="text"
class="tw-input"
value="SirStendec"
>
</div>
</div>
<div class="tw-flex tw-flex-shrink-0 tw-align-items-center">
<button class="tw-button tw-button--text" @click="del(idx)">
<span class="tw-button__text ffz-i-trash">
{{ t('setting.filters.delete', 'Delete') }}
</span>
</button>
</div>
</div>
<section v-if="! editing || ! editing.length" class="tw-mg-x-1 tw-mg-y-2 tw-c-text-alt-2 tw-align-center sortable-ignore">
{{ t('setting.filters.empty', '(no filters)') }}
</section>
<filter-rule-editor
v-for="rule in editing"
:key="rule.id"
:value="rule"
:filters="filters"
:context="context"
:data-id="rule.id"
@input="updateRule(rule.id, $event)"
@delete="deleteRule(rule.id)"
/>
</div>
<div v-if="adding || (maxRules === 1 && canAddRule)" class="tw-flex tw-align-items-center tw-mg-y-1">
<select
v-once
ref="add_box"
class="tw-flex-grow-1 tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-select"
>
<option
v-for="(filter, key) in filters"
:key="key"
:value="key"
>
{{ t(filter.i18n, filter.title) }}
</option>
</select>
<button class="tw-button tw-mg-l-1" @click="addRule">
<span class="tw-button__text ffz-i-plus">
{{ t('setting.filters.add', 'Add') }}
</span>
</button>
</div>
<button
v-else-if="canAddRule"
class="tw-button tw-button--hollow tw-mg-y-1 tw-full-width"
@click="newRule"
@click="adding = true"
>
<span class="tw-button__text ffz-i-plus">
{{ t('', 'Add New Rule') }}
{{ t('setting.filters.add-new', 'Add New Rule') }}
</span>
</button>
</div>
@ -54,12 +52,193 @@
<script>
import Sortable from 'sortablejs';
import {deep_copy, maybe_call, generateUUID} from 'utilities/object';
import {findSharedParent} from 'utilities/dom';
export default {
props: ['filters', 'rules', 'context'],
props: {
value: Array,
filters: Object,
maxRules: {
tpye: Number,
required: false,
default: 0
},
context: {
type: Object,
required: false
}
},
data() {
return {
adding: false,
editing: this.copyValue()
}
},
computed: {
canAddRule() {
return ! this.maxRules || (this.editing.length < this.maxRules);
}
},
watch: {
editing: {
handler() {
this.$emit('input', this.editing)
},
deep: true
}
},
mounted() {
this.sortable = Sortable.create(this.$refs.list, {
draggable: 'section',
filter: 'button,.sortable-ignore',
swapThreshold: 0.33,
group: {
name: 'ffz-filter-editor',
put: (to, from, dragged) => {
if ( ! this.canAddRule )
return false;
// Avoid moving an element into its child list.
if ( dragged && dragged.contains && dragged.contains(to.el) )
return false;
// Check to see if we have a common ancester for the two
// draggables.
if ( ! findSharedParent(to.el, from.el, '.ffz--rule-list') )
return false;
return true;
}
},
setData: (data, el) => {
const rule = this.getRule(el.dataset.id);
if ( rule ) {
data.setData('JSON', JSON.stringify(rule));
}
},
onAdd: event => {
if ( ! this.canAddRule ) {
event.preventDefault();
return;
}
let rule;
try {
rule = JSON.parse(event.originalEvent.dataTransfer.getData('JSON'));
} catch(err) {
event.preventDefault();
return;
}
this.editing.splice(event.newDraggableIndex, 0, rule);
},
onRemove: event => {
let rule;
try {
rule = JSON.parse(event.originalEvent.dataTransfer.getData('JSON'));
} catch(err) {
event.preventDefault();
return;
}
this.deleteRule(rule.id);
},
onUpdate: event => {
if ( event.newIndex === event.oldIndex )
return;
this.editing.splice(event.newIndex, 0, ...this.editing.splice(event.oldIndex, 1));
}
});
},
beforeDestroy() {
if ( this.sortable ) {
this.sortable.destroy();
this.sortable = null;
}
},
methods: {
newRule() {
getRule(id, start) {
if ( ! start )
start = this.editing;
if ( ! Array.isArray(start) )
return null;
for(let i=0; i < start.length; i++) {
const rule = start[i];
if ( ! rule )
continue;
else if ( rule.id === id )
return rule;
const type = this.filters[rule.type];
if ( type && type.childRules ) {
const out = this.getRule(id, rule.data);
if ( out )
return out;
}
}
return null;
},
addRule() {
this.adding = false;
const key = this.$refs.add_box.value,
type = this.filters[key];
if ( ! key )
return;
this.editing.push({
id: generateUUID(),
type: key,
data: maybe_call(type.default, type)
});
},
updateRule(id, data) {
for(let i=0; i < this.editing.length; i++) {
if ( this.editing[i].id === id ) {
this.editing[i] = Object.assign(this.editing[i], data);
return;
}
}
},
deleteRule(id) {
for(let i=0; i < this.editing.length; i++) {
if ( this.editing[i].id === id ) {
this.editing.splice(i, 1);
return;
}
}
},
copyValue() {
if ( ! Array.isArray(this.value) )
return [];
return deep_copy(this.value).map(rule => {
if ( ! rule.id )
rule.id = generateUUID();
return rule;
});
}
}
}

View file

@ -0,0 +1,127 @@
<template>
<section class="ffz--filter-rule-editor tw-elevation-1 tw-c-background-base tw-border tw-pd-y-05 tw-mg-y-05 tw-pd-r-1 tw-flex tw-flex-nowrap">
<div class="tw-flex tw-flex-shrink-0 tw-align-items-center handle tw-c-text-alt-2">
<h5 class="ffz-i-ellipsis-vert" />
</div>
<component
:is="component"
:type="type"
:filters="filters"
:context="context"
v-model="editing"
/>
<div
v-if="isShort"
class="tw-mg-l-1 tw-pd-x-1 tw-border-l tw-flex tw-align-items-center ffz--profile__icon tw-tooltip-wrapper"
>
<figure :class="[passes ? 'ffz-i-ok' : 'ffz-i-cancel']" />
<div class="tw-tooltip tw-tooltip--up tw-tooltip--align-right">
<span v-if="passes">
{{ t('setting.filters.active', 'This rule matches.') }}
</span>
<span v-else>
{{ t('setting.filters.inactive', 'This rule does not match.') }}
</span>
</div>
</div>
<div
:class="[isShort ? '' : 'tw-mg-l-1']"
class="tw-border-l tw-pd-l-1 tw-flex tw-flex-column tw-flex-wrap tw-justify-content-start tw-align-items-start"
>
<div v-if="! isShort" class="tw-mg-b-1 tw-border-b tw-pd-b-1 tw-full-width tw-flex tw-justify-content-center ffz--profile__icon tw-tooltip-wrapper">
<figure :class="[passes ? 'ffz-i-ok' : 'ffz-i-cancel']" />
<div class="tw-tooltip tw-tooltip--up tw-tooltip--align-right">
<span v-if="passes">
{{ t('setting.filters.active', 'This rule matches.') }}
</span>
<span v-else>
{{ t('setting.filters.inactive', 'This rule does not match.') }}
</span>
</div>
</div>
<template v-if="deleting">
<button class="tw-button tw-button--text tw-tooltip-wrapper" @click="$emit('delete')">
<span class="tw-button__text ffz-i-trash" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.delete', 'Delete') }}
</div>
</button>
<button class="tw-button tw-button--text tw-tooltip-wrapper" @click="deleting = false">
<span class="tw-button__text ffz-i-cancel" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.cancel', 'Cancel') }}
</div>
</button>
</template>
<template v-else>
<button class="tw-button tw-button--text tw-tooltip-wrapper" @click="deleting = true">
<span class="tw-button__text ffz-i-trash" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.delete', 'Delete') }}
</div>
</button>
</template>
</div>
</section>
</template>
<script>
import {deep_copy} from 'utilities/object';
export default {
props: {
value: Object,
filters: Object,
context: {
type: Object,
required: false
}
},
data() {
return {
editing: deep_copy(this.value),
tester: null,
deleting: false
}
},
computed: {
passes() {
return this.tester && this.tester(this.context);
},
type() {
return this.filters[this.editing && this.editing.type]
},
component() {
return this.type && this.type.editor;
},
isShort() {
return this.type && ! this.type.tall;
}
},
watch: {
editing: {
handler() {
this.tester = this.type.createTest(this.editing.data, this.filters);
this.$emit('input', this.editing);
},
deep: true
}
},
created() {
this.tester = this.type && this.type.createTest(this.editing && this.editing.data, this.filters);
}
}
</script>

View file

@ -20,11 +20,67 @@
{{ t('setting.delete', 'Delete') }}
</span>
</button>
<!--button class="tw-mg-l-1 tw-button tw-button--text">
<button
:class="{'tw-button--disabled': ! canExport}"
:disabled="! canExport"
class="tw-mg-l-1 tw-button tw-button--text"
@click="doExport"
>
<span class="tw-button__text ffz-i-download">
{{ t('setting.export', 'Export') }}
</span>
</button-->
</button>
</div>
<div v-if="export_error" class="tw-c-background-accent-alt-2 tw-c-text-overlay tw-pd-1 tw-mg-b-1 tw-flex tw-align-items-start">
<section class="tw-flex-grow-1">
<h4 class="ffz-i-attention">
{{ t('setting.backup-restore.error', 'There was an error processing this backup.') }}
</h4>
<div v-if="export_error_message">
{{ export_error_message }}
</div>
</section>
<button
class="tw-button tw-button--text tw-tooltip-wrapper"
@click="resetExport"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.close', 'Close') }}
</div>
</button>
</div>
<div v-if="export_message" class="tw-c-background-accent-alt-2 tw-c-text-overlay tw-pd-1 tw-mg-b-1 tw-flex tw-align-items-start">
<section class="tw-flex-grow-1">
{{ export_message }}
</section>
<button
class="tw-button tw-button--text tw-tooltip-wrapper"
@click="resetExport"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.close', 'Close') }}
</div>
</button>
</div>
<div v-if="url" class="tw-c-background-accent-alt-2 tw-c-text-overlay tw-pd-1 tw-mg-b-1">
<h5 class="ffz-i-download-cloud">
{{ t('setting.profile.updates', 'This profile will update automatically from the following URL:') }}
</h5>
<div>
<a
:href="url"
target="_blank"
rel="noopener noreferrer"
>
{{ url }}
</a>
</div>
</div>
<div class="ffz--menu-container tw-border-t">
@ -41,7 +97,7 @@
id="ffz:editor:name"
ref="name"
v-model="name"
class="tw-input"
class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-input"
>
</div>
@ -54,7 +110,7 @@
id="ffz:editor:description"
ref="desc"
v-model="desc"
class="tw-input"
class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-input"
/>
</div>
</div>
@ -71,9 +127,8 @@
<filter-editor
:filters="filters"
:rules="rules"
:context="test_context"
@change="unsaved = true"
v-model="rules"
/>
</div>
</div>
@ -81,21 +136,37 @@
<script>
import {deep_copy, deep_equals} from 'utilities/object';
import { saveAs } from 'file-saver';
export default {
props: ['item', 'context'],
data() {
return {
filters: deep_copy(require('src/settings/filters.js')),
old_name: null,
old_desc: null,
old_rules: null,
name: null,
desc: null,
url: null,
unsaved: false,
filters: null,
rules: null,
test_context: null
test_context: null,
export_error: false,
export_error_message: null,
export_message: null
}
},
computed: {
canExport() {
return this.item.profile != null
}
},
@ -108,6 +179,14 @@ export default {
desc() {
if ( this.desc !== this.old_desc )
this.unsaved = true;
},
rules: {
handler() {
if ( ! deep_equals(this.rules, this.old_rules) )
this.unsaved = true;
},
deep: true
}
},
@ -122,6 +201,33 @@ export default {
},
methods: {
resetExport() {
this.export_error = false;
this.export_error_message = null;
this.export_message = null;
},
doExport() {
this.resetExport();
let blob;
try {
const data = this.item.profile.getBackup();
blob = new Blob([JSON.stringify(data)], {type: 'application/json;charset=utf-8'});
} catch(err) {
this.export_error = true;
this.export_error_message = this.t('setting.backup-restore.dump-error', 'Unable to export settings data to JSON.');
return;
}
try {
saveAs(blob, `ffz-profile - ${this.name}.json`);
} catch(err) {
this.export_error = true;
this.export_error_message = this.t('setting.backup-restore.save-error', 'Unable to save.');
}
},
revert() {
const profile = this.item.profile;
@ -137,7 +243,8 @@ export default {
profile.description :
'';
this.rules = profile ? profile.context : {};
this.old_rules = this.rules = profile ? deep_copy(profile.context) : [];
this.url = profile ? profile.url : null;
this.unsaved = ! profile;
},
@ -161,13 +268,15 @@ export default {
if ( ! this.item.profile ) {
this.item.profile = this.context.createProfile({
name: this.name,
description: this.desc
description: this.desc,
context: this.rules
});
} else if ( this.unsaved ) {
const changes = {
name: this.name,
description: this.desc
description: this.desc,
context: this.rules
};
// Disable i18n if required.

View file

@ -1,26 +1,126 @@
<template lang="html">
<div class="ffz--widget ffz--profile-manager tw-border-t tw-pd-y-1">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1">
<h3 class="ffz-i-attention">
This feature is not yet finished.
</h3>
Creating and editing profiles is disabled until the rule editor is finished.
</div>
<div class="tw-flex tw-align-items-center tw-pd-b-05">
<div class="tw-flex-grow-1">
{{ t('setting.profiles.drag', 'Drag profiles to change their priority.') }}
</div>
<button class="tw-mg-l-1 tw-button tw-button--text" disabled @notclick="edit()">
<button
class="tw-mg-l-1 tw-button tw-button--text"
@click="edit()"
>
<span class="tw-button__text ffz-i-plus">
{{ t('setting.profiles.new', 'New Profile') }}
</span>
</button>
<!--button class="tw-mg-l-1 tw-button tw-button--text">
<button
class="tw-mg-l-1 tw-button tw-button--text"
@click="doImport"
>
<span class="tw-button__text ffz-i-upload">
{{ t('setting.import', 'Import…') }}
</span>
</button-->
</button>
</div>
<div v-if="import_error" class="tw-c-background-accent-alt-2 tw-c-text-overlay tw-pd-1 tw-mg-b-1 tw-flex tw-align-items-start">
<section class="tw-flex-grow-1">
<h4 class="ffz-i-attention">
{{ t('setting.backup-restore.error', 'There was an error processing this backup.') }}
</h4>
<div v-if="import_error_message">
{{ import_error_message }}
</div>
</section>
<button
class="tw-button tw-button--text tw-tooltip-wrapper"
@click="resetImport"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.close', 'Close') }}
</div>
</button>
</div>
<div v-if="import_message" class="tw-c-background-accent-alt-2 tw-c-text-overlay tw-pd-1 tw-mg-b-1 tw-flex tw-align-items-start">
<section class="tw-flex-grow-1">
{{ import_message }}
</section>
<button
class="tw-button tw-button--text tw-tooltip-wrapper"
@click="resetImport"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.close', 'Close') }}
</div>
</button>
</div>
<div v-if="import_profiles" class="tw-c-background-accent-alt-2 tw-c-text-overlay tw-pd-1 tw-mg-b-1 tw-flex tw-align-items-start">
<section class="tw-flex-grow-1">
<h4 class="ffz-i-upload">
{{ t('setting.backup-restore.pick-profile', 'Please select a profile to import.') }}
</h4>
<button
v-for="(profile, idx) in import_profiles"
:key="idx"
class="tw-block tw-full-width tw-mg-y-05 tw-mg-r-1 tw-pd-05 tw-button tw-button--hollow tw-tooltip-wrapper"
@click="importProfile(profile)"
>
<span class="tw-button__text">
{{ profile.i18n_key ? t(profile.i18n_key, profile.name) : profile.name }}
</span>
<div v-if="profile.description" class="tw-tooltip tw-tooltip--down tw-tooltip--align-left">
{{ profile.desc_i18n_key ? t(profile.desc_i18n_key, profile.description) : profile.description }}
</div>
</button>
</section>
<button
class="tw-button tw-button--text tw-tooltip-wrapper"
@click="resetImport"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.close', 'Close') }}
</div>
</button>
</div>
<div v-if="import_profile" class="tw-c-background-accent-alt-2 tw-c-text-overlay tw-pd-1 tw-mg-b-1 tw-flex tw-align-items-start">
<section class="tw-flex-grow-1">
<h4 class="ffz-i-help">
{{ t('setting.backup-restore.confirm-updates', 'The profile you are importing has an automatic update URL. Do you want the profile to keep itself up to date?') }}
</h4>
<button
class="tw-block tw-full-width tw-mg-y-05 tw-mg-r-1 tw-pd-05 tw-button tw-button--hollow"
@click="confirmImport(true)"
>
<span class="tw-button__text ffz-i-ok">
{{ t('setting.backup-restore.enable-auto', 'Yes, allow automatic updates.') }}
</span>
</button>
<button
class="tw-block tw-full-width tw-mg-y-05 tw-mg-r-1 tw-pd-05 tw-button tw-button--hollow"
@click="confirmImport(false)"
>
<span class="tw-button__text ffz-i-cancel">
{{ t('setting.backup-restore.disable-auto', 'No, prevent automatic updates.') }}
</span>
</button>
</section>
<button
class="tw-button tw-button--text tw-tooltip-wrapper"
@click="resetImport"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.close', 'Close') }}
</div>
</button>
</div>
<div ref="list" class="ffz--profile-list">
@ -38,15 +138,25 @@
<span class="ffz-i-ellipsis-vert" />
</div>
<div v-if="p.url" class="tw-flex tw-flex-shrink-0 tw-align-items-center tw-mg-r-1 tw-tooltip-wrapper tw-font-size-4">
<span class="ffz-i-download-cloud" />
<div class="tw-tooltip tw-tooltip--down tw-tooltip--align-left">
<div class="tw-mg-b-05">
{{ t('setting.profile.updates', 'This profile will update automatically from the following URL:') }}
</div>
{{ p.url }}
</div>
</div>
<div class="tw-flex-grow-1">
<h4>{{ t(p.i18n_key, p.title, p) }}</h4>
<h4>{{ p.i18n_key ? t(p.i18n_key, p.title, p) : p.title }}</h4>
<div v-if="p.description" class="description">
{{ t(p.desc_i18n_key, p.description, p) }}
{{ p.desc_i18n_key ? t(p.desc_i18n_key, p.description, p) : p.description }}
</div>
</div>
<div class="tw-flex tw-flex-shrink-0 tw-align-items-center">
<button class="tw-button tw-button--text" disabled @notclick="edit(p)">
<button class="tw-button tw-button--text" @click="edit(p)">
<span class="tw-button__text ffz-i-cog">
{{ t('setting.configure', 'Configure') }}
</span>
@ -75,9 +185,23 @@
import Sortable from 'sortablejs';
import {openFile, readFile} from 'utilities/dom';
import {deep_copy} from 'utilities/object';
import SettingsProfile from 'src/settings/profile';
export default {
props: ['item', 'context'],
data() {
return {
import_error: false,
import_error_message: null,
import_message: null,
import_profiles: null,
import_profile: null
}
},
mounted() {
this._sortable = Sortable.create(this.$refs.list, {
draggable: 'section',
@ -121,6 +245,114 @@ export default {
item.contents[0].parent = item;
this.$emit('change-item', item);
},
resetImport() {
this.import_error = false;
this.import_error_message = null;
this.import_message = null;
this.import_profiles = null;
this.import_profile = null;
this.import_profile_data = null;
this.import_data = null;
},
async doImport() {
this.resetImport();
let contents;
try {
contents = await readFile(await openFile('application/json'));
} catch(err) {
this.import_error = true;
this.import_error_message = this.t('setting.backup-restore.read-error', 'Unable to read file.');
return;
}
let data;
try {
data = JSON.parse(contents);
} catch(err) {
this.import_error = true;
this.import_error_message = this.t('setting.backup-restore.json-error', 'Unable to parse file as JSON.');
return;
}
if ( data && data.type === 'full' ) {
let profiles = data.values && data.values.profiles;
if ( profiles === undefined )
profiles = [
SettingsProfile.Moderation,
SettingsProfile.Default
];
if ( Array.isArray(profiles) ) {
this.import_data = data;
this.import_profiles = deep_copy(profiles);
return;
} else {
this.import_message = Object.keys(data.values);
}
} else if ( data && data.type === 'profile' ) {
if ( data.profile && data.values ) {
this.importProfile(data.profile, data.values);
return;
}
}
this.import_error = true;
this.import_error_message = this.t('setting.backup-restore.non-supported', 'This file is not recognized as a supported backup format.');
},
importProfile(profile_data, data) {
if ( profile_data.url ) {
this.import_profile = profile_data;
this.import_profile_data = data;
return;
}
this.confirmImport(false);
},
confirmImport(allow_update = false) {
const profile_data = this.import_profile,
data = this.import_profile_data;
const id = profile_data.id;
delete profile_data.id;
if ( ! allow_update )
delete profile_data.url;
const prof = this.context.createProfile(profile_data);
let i = 0;
if ( ! data ) {
const values = this.import_data && this.import_data.values,
prefix = `p:${id}:`;
if ( values )
for(const [key, value] of Object.entries(values)) {
if ( key.startsWith(prefix) ) {
prof.set(key.substr(prefix.length), value);
i++;
}
}
} else
for(const [key, value] of Object.entries(data)) {
prof.set(key, value);
i++;
}
this.resetImport();
this.import_message = this.t('setting.backup-restore.imported', 'The profile "{name}" has been successfully imported with {count,number} setting{count,en_plural}.', {
name: prof.i18n_key ? this.t(prof.i18n_key, prof.title) : prof.title,
count: i
});
}
}
}

View file

@ -55,6 +55,18 @@ export default {
mixins: [SettingMixin],
props: ['item', 'context'],
watch: {
value() {
if ( this.$refs.control )
this.$refs.control.indeterminate = this.value == null;
}
},
mounted() {
if ( this.$refs.control )
this.$refs.control.indeterminate = this.value == null;
},
methods: {
onChange() {
this.set(this.$refs.control.checked);

View file

@ -523,6 +523,8 @@ export default class MainMenu extends Module {
description: profile.description,
desc_i18n_key: profile.desc_i18n_key || profile.i18n_key && `${profile.i18n_key}.description`,
url: profile.url,
move: idx => context.manager.moveProfile(profile.id, idx),
save: () => profile.save(),
update: data => {
@ -530,6 +532,8 @@ export default class MainMenu extends Module {
profile.save()
},
getBackup: () => deep_copy(profile.getBackup()),
context: deep_copy(profile.context),
get: key => profile.get(key),
@ -575,14 +579,16 @@ export default class MainMenu extends Module {
off: (...args) => context.off(...args),
order: id => context.order.indexOf(id),
context: deep_copy(context.context),
context: deep_copy(context._context),
_update_profiles(changed) {
const new_list = [],
profiles = context.manager.__profiles;
for(let i=0; i < profiles.length; i++) {
const profile = profile_keys[profiles[i].id];
profile.order = i;
new_list.push(profile);
}
@ -611,11 +617,15 @@ export default class MainMenu extends Module {
},
_context_changed() {
this.context = deep_copy(context.context);
const ids = this.profiles = context.__profiles.map(profile => profile.id);
for(const id of ids) {
const profile = profiles[id];
profile.live = this.profiles.includes(profile.id);
this.context = deep_copy(context._context);
const profiles = context.manager.__profiles,
ids = this.profiles = context.__profiles.map(profile => profile.id);
for(let i=0; i < profiles.length; i++) {
const id = profiles[i].id,
profile = profile_keys[id];
profile.live = ids.includes(id);
}
},

View file

@ -0,0 +1,31 @@
<template>
<section class="tw-flex-grow-1 tw-align-self-start tw-flex tw-align-items-center">
<div class="tw-flex tw-align-items-center tw-checkbox tw-mg-y-05">
<input
:id="'enabled$' + value.id"
v-model="value.data"
type="checkbox"
class="tw-checkbox__input"
>
<label :for="'enabled$' + value.id" class="tw-checkbox__label">
{{ t(type.i18n, type.title) }}
</label>
</div>
</section>
</template>
<script>
let last_id = 0;
export default {
props: ['value', 'type', 'filters', 'context'],
data() {
return {
id: last_id++
}
}
}
</script>

View file

@ -0,0 +1,30 @@
<template>
<section class="tw-flex-grow-1 tw-align-self-start">
<header>
{{ t(type.i18n, type.title) }}
</header>
<filter-editor
:filters="filters"
:context="context"
:max-rules="type.maxRules"
v-model="value.data"
/>
</section>
</template>
<script>
let last_id = 0;
export default {
props: ['value', 'type', 'filters', 'context'],
data() {
return {
id: last_id++
}
}
}
</script>

View file

@ -0,0 +1,120 @@
<template>
<section class="tw-flex-grow-1 tw-align-self-start">
<div class="tw-flex tw-align-items-center">
<label :for="'page$' + id">
{{ t(type.i18n, type.title) }}
</label>
<select
:id="'page$' + id"
v-model="value.data.route"
class="tw-flex-grow-1 tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-select"
>
<option
v-for="(route, key) in routes"
v-once
:key="key"
:value="key"
>
{{ getName(key) }}
</option>
</select>
</div>
<div
v-if="parts && parts.length"
class="tw-border-t tw-mg-t-05"
>
<div
v-for="part in parts"
:key="part.key"
class="tw-flex tw-align-items-center tw-mg-t-05"
>
<label :for="'page$' + id + '$part-' + part.key">
{{ t(part.i18n, part.title) }}
</label>
<input
:id="'page$' + id + '$part-' + part.key"
v-model="value.data.values[part.key]"
class="tw-flex-grow-1 tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-input"
>
</div>
</div>
</section>
</template>
<script>
import {deep_copy} from 'utilities/object';
let last_id = 0;
export default {
props: ['value', 'type', 'filters', 'context'],
data() {
return {
id: last_id++,
routes: {},
route_names: {}
}
},
computed: {
route() {
return this.routes[this.value.data.route];
},
parts() {
const out = [];
if ( ! this.route || ! this.route.parts )
return out;
for(const part of this.route.parts) {
if ( typeof part === 'object' ) {
const name = part.name.replace(/([a-z])([A-Z])/g, (_, f, s) => `${f} ${s}`);
out.push({
key: part.name,
i18n: `settings.filter.page.route.${this.route.name}.${part.name}`,
title: name[0].toLocaleUpperCase() + name.substr(1)
});
}
}
return out;
}
},
watch: {
value: {
handler() {
},
deep: true
}
},
created() {
const ffz = FrankerFaceZ.get(),
router = this.router = ffz && ffz.resolve('site.router');
this.routes = deep_copy(router.getRoutes());
this.route_names = deep_copy(router.getRouteNames());
},
methods: {
getName(route) {
const i18n_key = `settings.filter.page.route.${route}`;
const title = this.route_names[route] = this.route_names[route] || route.replace(
/(^|-)([a-z])/g,
(_, spacer, letter) => `${spacer ? ' ' : ''}${letter.toLocaleUpperCase()}`
);
return this.t(i18n_key, title);
}
}
}
</script>

144
src/settings/filters.js Normal file
View file

@ -0,0 +1,144 @@
'use strict';
// ============================================================================
// Profile Filters for Settings
// ============================================================================
import {createTester} from 'utilities/filtering';
// Logical Components
export const Invert = {
createTest(config, rule_types) {
return createTester(config, rule_types, true)
},
maxRules: 1,
childRules: true,
tall: true,
title: 'Invert',
i18n: 'settings.filter.invert',
default: () => [],
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
};
export const Or = {
createTest(config, rule_types) {
return createTester(config, rule_types, false, true);
},
childRules: true,
tall: true,
title: 'Or',
i18n: 'settings.filter.or',
default: () => [],
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
};
// Context Stuff
export const TheaterMode = {
createTest(config) {
return ctx => ctx.ui && ctx.ui.theatreModeEnabled === config;
},
title: 'Theater Mode',
i18n: 'settings.filter.theater',
default: true,
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const Moderator = {
createTest(config) {
return ctx => ctx.moderator === config;
},
title: 'Is Moderator',
i18n: 'settings.filter.moderator',
default: true,
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const SquadMode = {
createTest(config) {
return ctx => ctx.ui && ctx.ui.squadModeEnabled === config;
},
title: 'Squad Mode',
i18n: 'settings.filter.squad',
default: true,
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const NativeDarkTheme = {
createTest(config) {
const val = config ? 1 : 0;
return ctx => ctx.ui && ctx.ui.theme === val;
},
title: 'Dark Theme',
i18n: 'settings.filter.native-dark',
default: true,
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const Page = {
createTest(config = {}) {
const name = config.route,
parts = [];
if ( Object.keys(config.values).length ) {
const ffz = FrankerFaceZ.get(),
router = ffz && ffz.resolve('site.router');
if ( router ) {
const route = router.getRoute(name);
if ( ! route || ! route.parts )
return () => false;
let i = 1;
for(const part of route.parts) {
if ( typeof part === 'object' ) {
if ( config.values[part.name] != null )
parts.push([i, config.values[part.name]]);
i++;
}
}
} else
return () => false;
}
return ctx => {
if ( ! ctx.route || ! ctx.route_data || ctx.route.name !== name )
return false;
for(const [index, value] of parts)
if ( ctx.route_data[index] !== value )
return false;
return true;
}
},
tall: true,
title: 'Current Page',
i18n: 'settings.filter.page',
default: () => ({
route: 'front-page',
values: {}
}),
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/page.vue')
};

View file

@ -5,7 +5,7 @@
// ============================================================================
import Module from 'utilities/module';
import {has} from 'utilities/object';
import {deep_equals, has} from 'utilities/object';
import {CloudStorageProvider, LocalStorageProvider} from './providers';
import SettingsProfile from './profile';
@ -92,6 +92,8 @@ export default class SettingsManager extends Module {
const duration = performance.now() - this._start_time;
this.log.info(`Initialization complete after ${duration.toFixed(5)}ms -- Values: ${this.provider.size} -- Profiles: ${this.__profiles.length}`)
this.scheduleUpdates();
}
@ -116,6 +118,34 @@ export default class SettingsManager extends Module {
}
scheduleUpdates() {
if ( this._update_timer )
clearTimeout(this._update_timer);
this._update_timer = setTimeout(() => this.checkUpdates(), 5000);
}
checkUpdates() {
const promises = [];
for(const profile of this.__profiles) {
if ( ! profile || ! profile.url )
continue;
const out = profile.checkUpdate();
promises.push(out instanceof Promise ? out : Promise.resolve(out));
}
Promise.all(promises).then(data => {
let success = 0;
for(const thing of data)
if ( thing )
success++;
this.log.info(`Successfully refreshed ${success} of ${data.length} profiles from remote URLs.`);
});
}
// ========================================================================
// Provider Interaction
@ -194,7 +224,6 @@ export default class SettingsManager extends Module {
// to keys.
old_ids = new Set(old_profiles.map(x => x.id)),
moved_ids = new Set,
new_ids = new Set,
changed_ids = new Set,
@ -203,30 +232,38 @@ export default class SettingsManager extends Module {
SettingsProfile.Default
]);
let changed = false;
let reordered = false,
changed = false;
for(const profile_data of raw_profiles) {
const id = profile_data.id,
slot_id = profiles.length,
old_profile = old_profile_ids[id],
old_slot_id = parseInt(old_profiles[profiles.length] || -1, 10);
old_slot_id = old_profile ? old_profiles.indexOf(old_profile) : -1;
old_ids.delete(id);
if ( old_slot_id !== id ) {
moved_ids.add(old_slot_id);
moved_ids.add(id);
if ( old_slot_id !== slot_id )
reordered = true;
// Monkey patch to the new profile format...
if ( profile_data.context && ! Array.isArray(profile_data.context) ) {
if ( profile_data.context.moderator )
profile_data.context = SettingsProfile.Moderation.context;
else
profile_data.context = null;
}
// TODO: Better method for checking if the profile data has changed.
if ( old_profile && JSON.stringify(old_profile.data) === JSON.stringify(profile_data) ) {
if ( old_profile && deep_equals(old_profile.data, profile_data, true) ) {
// Did the order change?
if ( old_profiles[profiles.length] !== old_profile )
if ( old_slot_id !== slot_id )
changed = true;
profiles.push(profile_ids[id] = old_profile);
continue;
}
const new_profile = profiles.push(profile_ids[id] = new SettingsProfile(this, profile_data));
const new_profile = profile_ids[id] = new SettingsProfile(this, profile_data);
if ( old_profile ) {
// Move all the listeners over.
new_profile.__listeners = old_profile.__listeners;
@ -237,6 +274,7 @@ export default class SettingsManager extends Module {
} else
new_ids.add(id);
profiles.push(new_profile);
changed = true;
}
@ -252,7 +290,7 @@ export default class SettingsManager extends Module {
for(const id of changed_ids)
this.emit(':profile-changed', profile_ids[id]);
if ( moved_ids.size )
if ( reordered )
this.emit(':profiles-reordered');
}

View file

@ -5,8 +5,12 @@
// ============================================================================
import {EventEmitter} from 'utilities/events';
import {has, filter_match} from 'utilities/object';
import {has} from 'utilities/object';
import {createTester} from 'utilities/filtering';
const fetchJSON = (url, options) => {
return fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
}
/**
* Instances of SettingsProfile are used for getting and setting raw settings
@ -35,6 +39,8 @@ export default class SettingsProfile extends EventEmitter {
description: this.description,
desc_i18n_key: this.desc_i18n_key,
url: this.url,
context: this.context
}
}
@ -43,24 +49,18 @@ export default class SettingsProfile extends EventEmitter {
if ( typeof val !== 'object' )
throw new TypeError('data must be an object');
this.matcher = null;
for(const key in val)
if ( has(val, key) )
this[key] = val[key];
}
matches(context) {
// If we don't have any specific context, then we work!
if ( ! this.context )
return true;
if ( ! this.matcher )
this.matcher = createTester(this.context, require('./filters'));
// If we do have context and didn't get any, then we don't!
else if ( ! context )
return false;
// Got context? Have context? One-sided deep comparison time.
// Let's go for a walk!
return filter_match(this.context, context);
return this.matcher(context);
}
@ -69,6 +69,46 @@ export default class SettingsProfile extends EventEmitter {
}
getBackup() {
const out = {
version: 2,
type: 'profile',
profile: this.data,
values: {}
};
for(const [k,v] of this.entries())
out.values[k] = v;
return out;
}
async checkUpdate() {
if ( ! this.url )
return false;
const data = fetchJSON(this.url);
if ( ! data || ! data.type === 'profile' || ! data.profile || ! data.values )
return false;
delete data.profile.id;
this.data = data.profile;
const old_keys = new Set(this.keys());
for(const [key, value] of Object.entries(data.values)) {
old_keys.delete(key);
this.set(key, value);
}
for(const key of old_keys)
this.delete(key);
return true;
}
// ========================================================================
// Context
// ========================================================================
@ -78,6 +118,7 @@ export default class SettingsProfile extends EventEmitter {
throw new Error('cannot set context of default profile');
this.context = Object.assign(this.context || {}, context);
this.matcher = null;
this.manager._saveProfiles();
}
@ -86,6 +127,7 @@ export default class SettingsProfile extends EventEmitter {
throw new Error('cannot set context of default profile');
this.context = context;
this.matcher = null;
this.manager._saveProfiles();
}
@ -172,7 +214,10 @@ SettingsProfile.Moderation = {
description: 'Settings that apply when you are a moderator of the current channel.',
context: {
moderator: true
}
context: [
{
type: 'Moderator',
data: true
}
]
}

View file

@ -39,6 +39,7 @@ export default class Twilight extends BaseSite {
this.web_munch.known(Twilight.KNOWN_MODULES);
this.router.route(Twilight.ROUTES);
this.router.routeName(Twilight.ROUTE_NAMES);
}
onEnable() {
@ -181,25 +182,35 @@ Twilight.CHAT_ROUTES = [
'user',
'dash',
'embed-chat'
]
];
Twilight.ROUTE_NAMES = {
'dir': 'Browse',
'dir-following': 'Following',
'dir-all': 'Browse Live Channels',
'dash': 'Dashboard',
'popout': 'Popout Chat',
'user-video': 'Channel Video'
};
Twilight.ROUTES = {
'front-page': '/',
'collection': '/collections/:collectionID',
'dir': '/directory',
'dir-community': '/communities/:communityName',
'dir-community-index': '/directory/communities',
'dir-creative': '/directory/creative',
//'dir-community': '/communities/:communityName',
//'dir-community-index': '/directory/communities',
//'dir-creative': '/directory/creative',
'dir-following': '/directory/following/:category?',
'dir-game-clips': '/directory/game/:gameName/clips',
'dir-game-details': '/directory/game/:gameName/details',
'dir-game-videos': '/directory/game/:gameName/videos/:filter',
'dir-game-index': '/directory/game/:gameName',
'dir-game-clips': '/directory/game/:gameName/clips',
'dir-game-videos': '/directory/game/:gameName/videos/:filter',
//'dir-game-details': '/directory/game/:gameName/details',
'dir-all': '/directory/all/:filter?',
'dir-category': '/directory/:category?',
//'dir-category': '/directory/:category?',
'dash': '/:userName/dashboard/:live?',
'dash-automod': '/:userName/dashboard/settings/automod',
//'dash-automod': '/:userName/dashboard/settings/automod',
'event': '/event/:eventName',
'popout': '/popout/:userName/chat',
'video': '/videos/:videoID',
@ -217,7 +228,7 @@ Twilight.ROUTES = {
'user': '/:userName',
'squad': '/:userName/squad',
'embed-chat': '/embed/:userName/chat'
}
};
Twilight.DIALOG_EXCLUSIVE = '.twilight-main,.twilight-minimal-root>div,#root>div>.tw-full-height,.clips-root';

View file

@ -227,11 +227,37 @@ export default class ChatHook extends Module {
});
this.settings.add('chat.bits.show-pinned', {
default: true,
requires: ['chat.bits.show'],
default: null,
process(ctx, val) {
if ( val != null )
return val;
return ctx.get('chat.bits.show')
},
ui: {
path: 'Chat > Bits and Cheering >> Appearance',
title: 'Display Top Cheerers',
description: 'By default, this inherits its value from Display Bits.',
component: 'setting-check-box'
}
});
this.settings.add('chat.bits.show-rewards', {
requires: ['chat.bits.show'],
default: null,
process(ctx, val) {
if ( val != null )
return val;
return ctx.get('chat.bits.show')
},
ui: {
path: 'Chat > Bits and Cheering >> Behavior',
title: 'Display messages when a cheer shares rewards to people in chat.',
description: 'By default, this inherits its value from Display Bits. This setting only affects newly arrived messages.',
component: 'setting-check-box'
}
});
@ -688,6 +714,9 @@ export default class ChatHook extends Module {
const types = t.chat_types || {},
mod_types = t.mod_types || {};
if ( msg.type === types.RewardGift && ! t.chat.context.get('chat.bits.show-rewards') )
return;
if ( msg.type === types.Message ) {
const m = t.chat.standardizeMessage(msg),
cont = inst._ffz_connector,
@ -1540,9 +1569,9 @@ export default class ChatHook extends Module {
moderator: props.isCurrentUserModerator,
channel: props.channelLogin && props.channelLogin.toLowerCase(),
channelID: props.channelID,
ui: {
/*ui: {
theme: props.theme
}
}*/
});
}
@ -1580,9 +1609,9 @@ export default class ChatHook extends Module {
moderator: props.isCurrentUserModerator,
channel: props.channelLogin && props.channelLogin.toLowerCase(),
channelID: props.channelID,
ui: {
/*ui: {
theme: props.theme
}
}*/
});
}

View file

@ -36,7 +36,7 @@ export default class Scroller extends Module {
this.settings.add('chat.scroller.freeze', {
default: 0,
ui: {
path: 'Chat > Behavior >> Scrolling @{"description": "Please note that FrankerFaceZ is dependant on Twitch\'s own scrolling code working correctly. There are bugs with Twitch\'s scrolling code that have existed for more than six months. If you are using Firefox, Edge, or other non-Webkit browsers, expect to have issues."}',
path: 'Chat > Behavior >> Scrolling @{"description": "Please note that FrankerFaceZ is dependent on Twitch\'s own scrolling code working correctly. There are bugs with Twitch\'s scrolling code that have existed for more than six months. If you are using Firefox, Edge, or other non-Webkit browsers, expect to have issues."}',
title: 'Pause Chat Scrolling',
description: 'Automatically stop chat from scrolling when moving the mouse over it or holding a key.',
component: 'setting-select-box',
@ -223,7 +223,8 @@ export default class Scroller extends Module {
this._ffz_installed = true;
const inst = this;
this._ffz_accessor = `_ffz_contains_${last_id++}`;
inst.ffz_outside = true;
inst._ffz_accessor = `_ffz_contains_${last_id++}`;
t.on('tooltips:mousemove', this.ffzTooltipHover, this);
t.on('tooltips:leave', this.ffzTooltipLeave, this);

View file

@ -185,6 +185,14 @@ export default class SettingsMenu extends Module {
});
}
closeMenu(inst) {
const super_parent = this.fine.searchParent(inst, n => n.setChatInputRef && n.setAutocompleteInputRef, 100),
parent = super_parent && this.fine.searchTree(super_parent, n => n.props && n.props.isSettingsOpen && n.onClickSettings);
if ( parent )
parent.onClickSettings();
}
click(inst, event) {
// If we're on a page with minimal root, we want to open settings
// in a popout as we're almost certainly within Popout Chat.
@ -219,7 +227,6 @@ export default class SettingsMenu extends Module {
this.emit('site.menu_button:clicked');
}
const parent = this.fine.searchParent(inst, n => n.toggleBalloonId);
parent && parent.handleButtonClick();
this.closeMenu(inst);
}
}

View file

@ -15,7 +15,9 @@ export default class FineRouter extends Module {
this.inject('..fine');
this.__routes = [];
this.routes = {};
this.route_names = {};
this.current = null;
this.current_name = null;
this.match = null;
@ -71,12 +73,45 @@ export default class FineRouter extends Module {
this.emit(':route', null, null);
}
route(name, path) {
getRoute(name) {
return this.routes[name];
}
getRoutes() {
return this.routes;
}
getRouteNames() {
return this.route_names;
}
getRouteName(route) {
if ( ! this.route_names[route] )
this.route_names[route] = route.replace(/(^|-)([a-z])/g, (_, spacer, letter) => `${spacer ? ' ' : ''}${letter.toLocaleUpperCase()}`);
return this.route_names[route];
}
routeName(route, name) {
if ( typeof route === 'object' ) {
for(const key in route)
if ( has(route, key) )
this.routeName(key, route[key]);
return;
}
this.route_names[route] = name;
}
route(name, path, sort = true) {
if ( typeof name === 'object' ) {
for(const key in name)
if ( has(name, key) )
this.route(key, name[key]);
this.route(key, name[key], false);
if ( sort )
this.__routes.sort((a,b) => b.score - a.score);
return;
}
@ -95,6 +130,7 @@ export default class FineRouter extends Module {
}
this.__routes.push(route);
this.__routes.sort((a,b) => b.score - a.score);
if ( sort )
this.__routes.sort((a,b) => b.score - a.score);
}
}

View file

@ -132,6 +132,20 @@ export function setChildren(el, children, no_sanitize, no_empty) {
}
export function findSharedParent(element, other, selector) {
while(element) {
if ( element.contains(other) )
return true;
element = element.parentElement;
if ( selector )
element = element && element.closest(selector);
}
return false;
}
export function openFile(contentType, multiple) {
return new Promise(resolve => {
const input = document.createElement('input');

View file

@ -4,3 +4,34 @@
// Advanced Filter System
// ============================================================================
export function createTester(rules, filter_types, inverted = false, or = false) {
if ( ! Array.isArray(rules) || ! filter_types )
return inverted ? () => false : () => true;
const tests = [],
names = [];
let i = 0;
for(const rule of rules) {
if ( ! rule || ! rule.type )
continue;
const type = filter_types[rule.type];
if ( ! type )
continue;
i++;
tests.push(type.createTest(rule.data, filter_types));
names.push(`f${i}`);
}
if ( ! tests.length )
return inverted ? () => false : () => true;
if ( tests.length === 1 )
return inverted ? ctx => ! tests[0](ctx) : tests[0];
return new Function(...names, 'ctx',
`return ${inverted ? `!(` : ''}${names.map(name => `${name}(ctx)`).join(or ? ' || ' : ' && ')}${inverted ? ')' : ''};`
).bind(null, ...tests);
}

View file

@ -172,6 +172,47 @@ export function array_equals(a, b) {
}
export function deep_equals(object, other, ignore_undefined = false, seen, other_seen) {
if ( object === other )
return true;
if ( typeof object !== typeof other )
return false;
if ( typeof object !== 'object' )
return false;
if ( ! seen )
seen = new Set;
if ( ! other_seen )
other_seen = new Set;
if ( seen.has(object) || other_seen.has(other) )
throw new Error('recursive structure detected');
seen.add(object);
other_seen.add(other);
const source_keys = Object.keys(object),
dest_keys = Object.keys(other);
if ( ! ignore_undefined && ! array_equals(source_keys, dest_keys) )
return false;
for(const key of source_keys)
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
return false;
if ( ignore_undefined )
for(const key of dest_keys)
if ( ! source_keys.includes(key) ) {
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
return false;
}
return true;
}
export function shallow_object_equals(a, b) {
if ( typeof a !== 'object' || typeof b !== 'object' || ! array_equals(Object.keys(a), Object.keys(b)) )
return false;
@ -312,6 +353,12 @@ export function deep_copy(object, seen) {
else if ( object === undefined )
return undefined;
if ( object instanceof Promise )
return new Promise((s,f) => object.then(s).catch(f));
if ( typeof object === 'function' )
return function(...args) { return object.apply(this, args); } // eslint-disable-line no-invalid-this
if ( typeof object !== 'object' )
return object;

View file

@ -135,6 +135,7 @@
.ffz-i-keyboard:before { content: '\f11c'; } /* '' */
.ffz-i-calendar-empty:before { content: '\f133'; } /* '' */
.ffz-i-ellipsis-vert:before { content: '\f142'; } /* '' */
.ffz-i-language:before { content: '\f1ab'; } /* '' */
.ffz-i-twitch:before { content: '\f1e8'; } /* '' */
.ffz-i-bell-off:before { content: '\f1f7'; } /* '' */
.ffz-i-trash:before { content: '\f1f8'; } /* '' */

View file

@ -12,6 +12,7 @@
@import "./widgets/color-picker.scss";
@import "./widgets/icon-picker.scss";
@import "./widgets/check-box.scss";
.tw-display-inline { display: inline !important }
.tw-width-auto { width: auto !important }
@ -26,6 +27,10 @@
}
}
textarea.tw-input {
height: unset;
}
.ffz--widget {
input, select {
min-width: 20rem;
@ -125,15 +130,23 @@
}
}
.ffz--profile {
.ffz-i-ok { color: green }
}
.sortable-ghost {
opacity: 0.25
}
}
.ffz--profile__icon {
&.ffz-i-ok,
.ffz-i-ok {
color: green;
}
&.ffz-i-cancel,
.ffz-i-cancel {
color: red;
}
}
.ffz--filter-editor {
.ffz--rule {

View file

@ -0,0 +1,23 @@
.tw-checkbox__input {
&:indeterminate + .tw-checkbox__label {
&:before {
background: #7d5bbe;
border: 1px solid #7d5bbe;
}
&:after {
content: '';
display: block;
position: absolute;
top: 50%;
left: .4rem;
height: 0;
width: .8rem;
margin-top: -.1rem;
border-bottom: 2px solid #fff;
border-radius: 1rem;
}
}
}