1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-02 16:08:31 +00:00

The In-Line Actions Update

* Add extensible actions system.
* Add extensive UI for configuring the actions system.
* Add setting to disable channel hosting.
* Fix the stupid Rooms thing popping up every time you open a channel.
* Fix how we grab chat types from React.
* Refactor how we handle incoming chat messages.
* Add a hook for outgoing chat messages.
* Fix emoji appearing squished with baseline emote alignment.
* Display arrows on balloons.
* Fix an issue generating emoji URLs.
* Do not use the default values for settings with merge strategies if profiles have those settings, just empty.
* Display a message in the chat settings menu if we tried opening FFZ's settings and failed.
* Wait a bit for webpack's loader if it's not immediately there for some reason.
* Probably other stuff.
* Not mod cards. Yet.
This commit is contained in:
SirStendec 2018-04-28 17:56:03 -04:00
parent e9214bb46a
commit fdde05030f
67 changed files with 7689 additions and 226 deletions

View file

@ -1,3 +1,20 @@
<div class="list-header">4.0.0-rc1<span>@a1a7fb774d62948bacc5</span> <time datetime="2018-04-28">(2018-04-28)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Added: Custom In-Line Chat Actions</li>
<li>Added: Option to disable Channel Hosting.</li>
<li>Changed: Minor refactoring of how we wrap incoming chat events, since Twitch is now filtering by chat room themselves for most events.</li>
<li>Fixed: Grab chat types from React correctly.</li>
<li>Fixed: Emoji appearing squashed in chat when using the baseline emote alignment option.</li>
<li>Fixed: Strip new lines from outgoing chat messages to ensure we process them correctly, locally.</li>
<li>Fixed: Display arrows on balloons for channel metadata.</li>
<li>Fixed: Validate the requested emoji style when getting image URLs.</li>
<li>Fixed: Do not use default values for settings with merge strategies if profiles are present and empty.</li>
<li>Fixed: Display a message in the chat settings menu if we tried opening FFZ settings in a pop-up and failed.</li>
<li>Fixed: Wait a bit for webpack's loader to be found if it is not immediately present in window.</li>
<li>Fixed: Forcibly close the chat room picker when first loading a channel.</li>
<li>Fixed: Vertically align FFZ icon for emote menu with bits icon.</li>
</ul>
<div class="list-header">4.0.0-beta2.18.2<span>@a1a7fb774d62948bacc5</span> <time datetime="2018-04-16">(2018-04-16)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Fixed: Rewrite stacktraces for automatic error reporting to use the permanent URL for the current FrankerFaceZ build.</li>

25
package-lock.json generated
View file

@ -5384,6 +5384,11 @@
"integrity": "sha1-0jM6NtnncXyK0vfKyv7HwytERmQ=",
"dev": true
},
"lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
},
"lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@ -5474,6 +5479,11 @@
"object-visit": "1.0.1"
}
},
"material-colors": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.5.tgz",
"integrity": "sha1-UpJZPmdUyxvMK5gDDk4Najr8nqE="
},
"math-expression-evaluator": {
"version": "1.2.17",
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz",
@ -8797,6 +8807,11 @@
"setimmediate": "1.0.5"
}
},
"tinycolor2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.1.tgz",
"integrity": "sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g="
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@ -9356,6 +9371,16 @@
"loose-envify": "1.3.1"
}
},
"vue-color": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/vue-color/-/vue-color-2.4.6.tgz",
"integrity": "sha512-kGz1LNu1DKIcT8jQmUeSjQNqh9NGYBCvMFXU43MRF4kg7A33Z4tZMQFLrPrX7d8r3ptbmLq+MaclJiVQFMCMqg==",
"requires": {
"lodash.throttle": "4.1.1",
"material-colors": "1.2.5",
"tinycolor2": "1.4.1"
}
},
"vue-eslint-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-2.0.3.tgz",

View file

@ -61,6 +61,7 @@
"sortablejs": "^1.7.0",
"vue": "^2.5.16",
"vue-clickaway": "^2.1.0",
"vue-color": "^2.4.6",
"vue-template-compiler": "^2.5.16",
"vuedraggable": "^2.16.0"
}

Binary file not shown.

View file

@ -84,6 +84,10 @@
<glyph glyph-name="pin-outline" unicode="&#xe826;" d="M856 554q30-30 30-73t-30-75q-16-16-36-24-106-51-144-125-51-102-51-246 0-45-29-74t-75-30q-47 0-75 30l-167 169-279-199 199 279-169 168q-20 18-27 49t5 64q27 65 96 65 146 0 246 50l11 7q69 37 118 141 7 20 21 34 30 30 74 29t74-31z m-273-250q53 107 196 175l-205 211q-69-146-176-200l-13-6q-120-57-281-57l416-416q0 168 63 293z" horiz-adv-x="886" />
<glyph glyph-name="gift" unicode="&#xe827;" d="M518 93v400h-179v-400q0-14 10-21t26-8h107q16 0 26 8t10 21z m-255 471h109l-70 90q-15 17-39 17-22 0-38-15t-15-38 15-38 38-16z m384 54q0 22-15 38t-38 15q-24 0-39-17l-69-90h108q22 0 38 16t15 38z m210-143v-179q0-7-5-12t-13-5h-53v-233q0-22-16-37t-38-16h-607q-22 0-38 16t-16 37v233h-53q-8 0-13 5t-5 12v179q0 8 5 13t13 5h245q-51 0-88 36t-37 89 37 88 88 37q60 0 94-43l72-92 71 92q34 43 94 43 52 0 88-37t37-88-37-89-88-36h245q8 0 13-5t5-13z" horiz-adv-x="857.1" />
<glyph glyph-name="link-ext" unicode="&#xf08e;" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" />
<glyph glyph-name="twitter" unicode="&#xf099;" d="M904 622q-37-54-90-93 0-8 0-23 0-73-21-145t-64-139-103-117-144-82-181-30q-151 0-276 81 19-2 43-2 126 0 224 77-59 1-105 36t-64 89q19-3 34-3 24 0 48 6-63 13-104 62t-41 115v2q38-21 82-23-37 25-59 64t-22 86q0 49 25 91 68-83 164-133t208-55q-5 21-5 41 0 75 53 127t127 53q79 0 132-57 61 12 115 44-21-64-80-100 52 6 104 28z" horiz-adv-x="928.6" />
<glyph glyph-name="gauge" unicode="&#xf0e4;" d="M214 207q0 30-21 51t-50 21-51-21-21-51 21-50 51-21 50 21 21 50z m107 250q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m239-268l57 213q3 14-5 27t-21 16-27-3-17-22l-56-213q-33-3-60-25t-35-55q-11-43 11-81t66-50 81 11 50 66q9 33-4 65t-40 51z m369 18q0 30-21 51t-51 21-50-21-21-51 21-50 50-21 51 21 21 50z m-358 357q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m250-107q0 30-20 51t-51 21-50-21-21-51 21-50 50-21 51 21 20 50z m179-250q0-145-79-269-10-17-30-17h-782q-20 0-30 17-79 123-79 269 0 102 40 194t106 160 160 107 194 39 194-39 160-107 106-160 40-194z" horiz-adv-x="1000" />
@ -104,6 +108,8 @@
<glyph glyph-name="trash" unicode="&#xf1f8;" d="M286 82v393q0 8-5 13t-13 5h-36q-8 0-13-5t-5-13v-393q0-8 5-13t13-5h36q8 0 13 5t5 13z m143 0v393q0 8-5 13t-13 5h-36q-8 0-13-5t-5-13v-393q0-8 5-13t13-5h36q8 0 13 5t5 13z m142 0v393q0 8-5 13t-12 5h-36q-8 0-13-5t-5-13v-393q0-8 5-13t13-5h36q7 0 12 5t5 13z m-303 554h250l-27 65q-4 5-9 6h-177q-6-1-10-6z m518-18v-36q0-8-5-13t-13-5h-54v-529q0-46-26-80t-63-34h-464q-37 0-63 33t-27 79v531h-53q-8 0-13 5t-5 13v36q0 8 5 13t13 5h172l39 93q9 21 31 35t44 15h178q23 0 44-15t30-35l39-93h173q8 0 13-5t5-13z" horiz-adv-x="785.7" />
<glyph glyph-name="eyedropper" unicode="&#xf1fb;" d="M948 798q52-53 52-127t-52-126l-126-124 58-58q6-6 6-13t-6-13l-117-117q-6-6-13-6t-13 6l-58 59-337-337q-21-21-50-21h-113l-143-71-36 36 71 143v113q0 29 21 50l337 337-59 58q-6 6-6 13t6 13l117 117q6 6 13 6t13-6l58-58 124 126q52 52 126 52t127-52z m-662-769l321 321-107 107-321-321v-107h107z" horiz-adv-x="1000" />
<glyph glyph-name="window-maximize" unicode="&#xf2d0;" d="M143 64h714v429h-714v-429z m857 625v-678q0-37-26-63t-63-27h-822q-36 0-63 27t-26 63v678q0 37 26 63t63 27h822q37 0 63-27t26-63z" horiz-adv-x="1000" />
<glyph glyph-name="window-minimize" unicode="&#xf2d1;" d="M1000 118v-107q0-37-26-63t-63-27h-822q-36 0-63 27t-26 63v107q0 37 26 63t63 26h822q37 0 63-26t26-63z" horiz-adv-x="1000" />

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -13,6 +13,7 @@
script = document.createElement('script');
script.id = 'ffz-script';
script.async = true;
script.crossOrigin = 'anonymous';
script.src = `${SERVER}/script/${BABEL}${FLAVOR}.js?_=${Date.now()}`;
document.head.appendChild(script);

View file

@ -7,7 +7,7 @@
// ============================================================================
import {SERVER} from 'utilities/constants';
import {pick_random, has} from 'utilities/object';
import {get, pick_random, has} from 'utilities/object';
import Module from 'utilities/module';
@ -519,10 +519,10 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form
if ( typeof result === 'string' )
result = REPLACE.call(result, token_regex, (expr, arg, fmt) => {
if ( ! has(options, arg) )
let val = get(arg, options);
if ( val == null )
return '';
let val = options[arg];
const formatter = formatters[fmt];
if ( typeof formatter === 'function' )
val = formatter(val, locale, options);

View file

@ -100,7 +100,7 @@ class FrankerFaceZ extends Module {
FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = {
major: 4, minor: 0, revision: 0, extra: '-beta2.18.3',
major: 4, minor: 0, revision: 0, extra: '-rc1',
build: __webpack_hash__,
toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}`

View file

@ -0,0 +1,24 @@
<template lang="html">
<div>
<div class="tw-flex tw-align-items-center">
<label for="edit_reason">
{{ t('setting.actions.reason', 'Default Reason') }}
</label>
<input
id="edit_reason"
v-model.trim="value.reason"
class="tw-mg-y-05 tw-input tw-display-inline"
@input="$emit('input', value)"
>
</div>
</div>
</template>
<script>
export default {
props: ['value', 'defaults']
}
</script>

View file

@ -0,0 +1,29 @@
<template lang="html">
<div class="tw-flex tw-align-items-start">
<label for="edit_chat" class="tw-mg-y-05">
{{ t('setting.actions.chat', 'Chat Command') }}
</label>
<div class="tw-full-width">
<input
id="edit_chat"
v-model="value.command"
:placeholder="defaults.command"
class="tw-mg-y-05 tw-input tw-display-inline"
@input="$emit('input', value)"
>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.variables', 'Available Variables: %{vars}', {vars}) }}
</div>
</div>
</div>
</template>
<script>
export default {
props: ['value', 'defaults', 'vars'],
}
</script>

View file

@ -0,0 +1,132 @@
<template lang="html">
<div class="tw-flex tw-align-items-start">
<label class="tw-mg-y-05">
{{ t('setting.actions.icon', 'Icon') }}
</label>
<div class="tw-full-width">
<div class="tw-search-input">
<label for="icon-search" class="tw-hide-accessible">
{{ t('setting.actions.icon.search', 'Search for Icon') }}
</label>
<div class="tw-relative tw-mg-t-05">
<div class="tw-input__icon-group tw-top-0 tw-left-0 tw-z-default tw-absolute">
<div class="tw-input__icon tw-c-text-alt-2 tw-align-items-center tw-flex tw-justify-content-center">
<figure class="ffz-i-search" />
</div>
</div>
<input
id="icon-search"
:placeholder="t('setting.actions.icon.search', 'Search for Icon')"
v-model="search"
type="search"
class="tw-input tw-pd-l-3"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
>
</div>
</div>
<simplebar classes="tw-c-background-alt-2 tw-border-l tw-border-r tw-border-b ffz-icon-picker tw-mg-b-05">
<div v-if="visible.length" role="radiogroup" class="tw-pd-1 tw-flex tw-flex-wrap" >
<div
v-for="i of visible"
:key="i[0]"
:aria-checked="value.icon === i[0]"
:class="{'tw-interactable--selected': value.icon === i[0]}"
class="ffz-icon tw-interactable"
role="radio"
tabindex="0"
@keydown.space.stop.prevent=""
@keyup.space="change(i[0])"
@keyup.enter="change(i[0])"
@click="change(i[0])"
>
<figure :class="`tw-mg-y-05 tw-mg-x-1 ${i[0]}`" />
</div>
</div>
<div v-else class="tw-align-center tw-pd-1 tw-c-text-alt-2">
{{ t('setting.actions.empty-search', 'no results') }}
</div>
</simplebar>
</div>
</div>
</template>
<script>
import {escape_regex, deep_copy} from 'utilities/object';
import {load, ICONS as FA_ICONS, ALIASES as FA_ALIASES} from 'utilities/font-awesome';
const FFZ_ICONS = [
'zreknarf',
'crown',
'verified',
'inventory',
'ignore',
'pin-outline',
'pin',
'block',
'ok',
'clock'
];
const FFZ_ALIASES = {
'block': ['ban', 'block'],
'ok': ['ok', 'unban', 'untimeout', 'checkmark'],
'clock': ['clock', 'clock-o', 'timeout']
};
const ICONS = FFZ_ICONS
.map(x => [`ffz-i-${x}`, FFZ_ALIASES[x] ? FFZ_ALIASES[x].join(' ') : x])
.concat(FA_ICONS.filter(x => ! FFZ_ICONS.includes(x)).map(x => [`ffz-fa fa-${x}`, FA_ALIASES[x] ? FA_ALIASES[x].join(' ') : x]));
export default {
props: ['value'],
data() {
return {
search: '',
icons: deep_copy(ICONS)
}
},
computed: {
visible() {
if ( ! this.search || ! this.search.length )
return this.icons;
const search = this.search.toLowerCase().replace(' ', '-'),
reg = new RegExp('(?:^|-| )' + escape_regex(search), 'i');
return this.icons.filter(x => reg.test(x[1]));
}
},
mounted() {
load();
},
methods: {
change(val) {
this.value.icon = val;
this.$emit('input', this.value);
}
}
}
</script>
<style lang="scss" scoped>
.ffz-icon-picker {
max-height: 15rem;
font-size: 1.6rem;
.ffz-icon {
width: auto !important;
}
}
</style>

View file

@ -0,0 +1,22 @@
<template lang="html">
<div class="tw-flex tw-align-items-center">
<label for="edit_image">
{{ t('setting.actions.url', 'URL') }}
</label>
<input
id="edit_image"
v-model="value.image"
class="tw-mg-y-05 tw-input tw-display-inline"
@input="$emit('input', value)"
>
</div>
</template>
<script>
export default {
props: ['value']
}
</script>

View file

@ -0,0 +1,22 @@
<template lang="html">
<div class="tw-flex tw-align-items-center">
<label for="edit_text">
{{ t('setting.actions.text', 'Label') }}
</label>
<input
id="edit_text"
v-model="value.text"
class="tw-mg-y-05 tw-input tw-display-inline"
@input="$emit('input', value)"
>
</div>
</template>
<script>
export default {
props: ['value']
}
</script>

View file

@ -0,0 +1,39 @@
<template lang="html">
<div>
<div class="tw-flex tw-align-items-center">
<label for="edit_duration">
{{ t('setting.actions.duration', 'Duration') }}
</label>
<input
id="edit_duration"
v-model.number="value.duration"
:placeholder="defaults.duration"
class="tw-mg-y-05 tw-input tw-display-inline"
type="number"
@input="$emit('input', value)"
>
</div>
<div class="tw-flex tw-align-items-center">
<label for="edit_reason">
{{ t('setting.actions.reason', 'Default Reason') }}
</label>
<input
id="edit_reason"
v-model.trim="value.reason"
class="tw-mg-y-05 tw-input tw-display-inline"
@input="$emit('input', value)"
>
</div>
</div>
</template>
<script>
export default {
props: ['value', 'defaults']
}
</script>

View file

@ -0,0 +1,29 @@
<template lang="html">
<div class="tw-flex tw-align-items-start">
<label for="edit_url" class="tw-mg-y-05">
{{ t('setting.actions.url', 'URL') }}
</label>
<div class="tw-full-width">
<input
id="edit_url"
v-model="value.url"
:placeholder="defaults.url"
class="tw-mg-y-05 tw-input tw-display-inline"
@input="$emit('input', value)"
>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.variables', 'Available Variables: %{vars}', {vars}) }}
</div>
</div>
</div>
</template>
<script>
export default {
props: ['value', 'defaults', 'vars'],
}
</script>

View file

@ -0,0 +1,303 @@
'use strict';
// ============================================================================
// Emoji Handling
// ============================================================================
import Module from 'utilities/module';
import {has, maybe_call, deep_copy} from 'utilities/object';
import {ClickOutside} from 'utilities/dom';
import Tooltip from 'utilities/tooltip';
import * as ACTIONS from './types';
import * as RENDERERS from './renderers';
export default class Actions extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('tooltips');
this.inject('i18n');
this.actions = {};
this.renderers = {};
this.settings.add('chat.actions.inline', {
// Filter out actions
process: (ctx, val) =>
val.filter(x => x.appearance &&
this.renderers[x.appearance.type] &&
(! this.renderers[x.appearance.type].load || this.renderers[x.appearance.type].load(x.appearance)) &&
(! x.action || this.actions[x.action])
),
default: [
{v: {action: 'ban', appearance: {type: 'icon', icon: 'ffz-i-block'}, display: {mod: true, mod_icons: true, deleted: false}}},
{v: {action: 'unban', appearance: {type: 'icon', icon: 'ffz-i-ok'}, display: {mod: true, mod_icons: true, deleted: true}}},
{v: {action: 'timeout', appearance: {type: 'icon', icon: 'ffz-i-clock'}, display: {mod: true, mod_icons: true}}},
],
type: 'array_merge',
ui: {
path: 'Chat > In-Line Actions',
component: 'chat-actions',
inline: true,
data: () => {
const chat = this.resolve('site.chat');
return {
color: val => chat && chat.colors ? chat.colors.process(val) : val,
actions: deep_copy(this.actions),
renderers: deep_copy(this.renderers)
}
}
}
});
this.handleClick = this.handleClick.bind(this);
this.handleContext = this.handleContext.bind(this);
}
onEnable() {
this.tooltips.types.action = (target, tip) => {
const data = this.getData(target);
if ( ! data )
return this.i18n.t('chat.actions.unknown', 'Unknown Action Type');
if ( ! data.definition.tooltip )
return `Error: The "${data.action}" action provider does not have tooltip support.`;
if ( data.tip && data.tip.length )
return data.tip;
return maybe_call(data.definition.tooltip, this, data, target, tip);
}
for(const key in ACTIONS)
if ( has(ACTIONS, key) )
this.addAction(key, ACTIONS[key]);
for(const key in RENDERERS)
if ( has(RENDERERS, key) )
this.addRenderer(key, RENDERERS[key]);
}
addAction(key, data) {
if ( has(this.actions, key) )
return this.log.warn(`Attempted to add action "${key}" which is already defined.`);
this.actions[key] = data;
for(const ctx of this.settings.__contexts)
ctx.update('chat.actions.inline');
}
addRenderer(key, data) {
if ( has(this.renderers, key) )
return this.log.warn(`Attempted to add renderer "${key}" which is already defined.`);
this.renderers[key] = data;
for(const ctx of this.settings.__contexts)
ctx.update('chat.actions.inline');
}
renderInlineContext(target, data) {
if ( target._ffz_destroy )
return target._ffz_destroy();
const destroy = target._ffz_destroy = () => {
if ( target._ffz_outside )
target._ffz_outside.destroy();
if ( target._ffz_popup ) {
const fp = target._ffz_popup;
target._ffz_popup = null;
fp.destroy();
}
target._ffz_destroy = target._ffz_outside = null;
}
const parent = document.body.querySelector('.twilight-root,.twilight-minimal-root') || document.body,
tt = target._ffz_popup = new Tooltip(parent, target, {
logger: this.log,
manual: true,
html: true,
tooltipClass: 'ffz-action-balloon tw-balloon tw-block tw-border tw-elevation-1 tw-border-radius-small tw-c-background',
arrowClass: 'tw-balloon__tail tw-overflow-hidden tw-absolute',
arrowInner: 'tw-balloon__tail-symbol tw-border-t tw-border-r tw-border-b tw-border-l tw-border-radius-small tw-c-background tw-absolute',
innerClass: 'tw-pd-1',
popper: {
placement: 'bottom',
modifiers: {
preventOverflow: {
boundariesElement: parent
},
flip: {
behavior: ['bottom', 'top', 'left', 'right']
}
}
},
content: (t, tip) => data.definition.context.call(this, data, t, tip),
onShow: (t, tip) =>
setTimeout(() => {
target._ffz_outside = new ClickOutside(tip.outer, destroy)
}),
onHide: destroy
});
tt._enter(target);
}
renderInline(msg, mod_icons, current_user, current_room, createElement) {
const actions = [];
if ( msg.user && current_user.login === msg.user.userLogin )
return;
const chat = this.resolve('site.chat');
for(const data of this.parent.context.get('chat.actions.inline')) {
if ( ! data.action || ! data.appearance )
continue;
const ap = data.appearance || {},
disp = data.display || {},
def = this.renderers[ap.type];
if ( ! def || disp.disabled ||
(disp.mod_icons != null && disp.mod_icons !== mod_icons) ||
(disp.mod != null && disp.mod !== current_user.moderator) ||
(disp.staff != null && disp.staff !== current_user.staff) ||
(disp.deleted != null && disp.deleted !== msg.deleted) )
continue;
const has_color = def.colored && ap.color,
color = has_color && (chat && chat.colors ? chat.colors.process(ap.color) : ap.color),
contents = def.render.call(this, ap, createElement, color);
actions.push(<button
class={`ffz-tooltip ffz-mod-icon mod-icon tw-c-text-alt-2${has_color ? ' colored' : ''}`}
data-tooltip-type="action"
data-action={data.action}
data-options={data.options ? JSON.stringify(data.options) : null}
data-tip={ap.tooltip}
onClick={this.handleClick}
onContextMenu={this.handleContext}
>
{contents}
</button>);
}
if ( ! actions.length )
return null;
const room = current_room && JSON.stringify(current_room),
user = msg.user && JSON.stringify({
login: msg.user.userLogin,
displayName: msg.user.userDisplayName,
id: msg.user.userID,
type: msg.user.userType
});
return (<div
class="ffz--inline-actions tw-inline tw-mg-r-05"
data-msg-id={msg.id}
data-user={user}
data-room={room}
>
{actions}
</div>);
}
getData(element) {
const ds = element.dataset,
parent = element.parentElement,
pds = parent && parent.dataset,
action = ds && ds.action,
definition = this.actions[action];
if ( ! definition )
return null;
const user = pds && pds.user ? JSON.parse(pds.user) : null,
room = pds && pds.room ? JSON.parse(pds.room) : null,
message_id = pds && pds.msgId,
data = {
action,
definition,
tip: ds.tip,
options: ds.options ? JSON.parse(ds.options) : null,
user,
room,
message_id
};
if ( definition.defaults )
data.options = Object.assign({}, maybe_call(definition.defaults, this, data, element), data.options);
return data;
}
handleClick(event) {
const target = event.target,
data = this.getData(target);
if ( ! data )
return;
if ( ! data.definition.click ) {
if ( data.definition.context )
return this.handleContext(event);
return this.log.warn(`No click handler for action provider "${data.action}"`);
}
if ( target._ffz_tooltip$0 )
target._ffz_tooltip$0.hide();
return data.definition.click.call(this, event, data);
}
handleContext(event) {
if ( event.shiftKey )
return;
event.preventDefault();
const target = event.target,
data = this.getData(event.target);
if ( ! data )
return;
if ( ! data.definition.context )
return;
if ( target._ffz_tooltip$0 )
target._ffz_tooltip$0.hide();
this.renderInlineContext(event.target, data);
}
sendMessage(room, message) {
return this.resolve('site.chat').sendMessage(room, message);
}
}

View file

@ -0,0 +1,14 @@
<template>
<figure
:class="`${data.icon||'ffz-i-zreknarf'}`"
:style="{color}"
/>
</template>
<script>
export default {
props: ['data', 'color']
}
</script>

View file

@ -0,0 +1,13 @@
<template>
<figure class="mod-icon__image">
<img :src="data.image">
</figure>
</template>
<script>
export default {
props: ['data', 'color']
}
</script>

View file

@ -0,0 +1,13 @@
<template>
<span :style="{color}">
{{ data.text }}
</span>
</template>
<script>
export default {
props: ['data', 'color']
}
</script>

View file

@ -0,0 +1,63 @@
'use strict';
import {load as loadFontAwesome} from 'utilities/font-awesome';
// ============================================================================
// Text
// ============================================================================
export const text = {
title: 'Text',
title_i18n: 'setting.actions.appearance.text',
colored: true,
preview: () => import(/* webpackChunkName: 'main-menu' */ './preview-text.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-text.vue'),
render(data, createElement, color) {
return <span style={{color}}>{data.text}</span>;
}
}
// ============================================================================
// Icon
// ============================================================================
export const icon = {
title: 'Icon',
title_i18n: 'setting.actions.appearance.icon',
colored: true,
preview: () => import(/* webpackChunkName: 'main-menu' */ './preview-icon.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-icon.vue'),
load(data) {
if ( data.icon && data.icon.startsWith('ffz-fa') )
loadFontAwesome();
return true;
},
render(data, createElement, color) {
return <figure style={{color}} class={`${data.icon||'ffz-i-zreknarf'}`} />;
}
}
// ============================================================================
// Image
// ============================================================================
export const image = {
title: 'Image',
title_i18n: 'setting.actions.appearance.image',
preview: () => import(/* webpackChunkName: 'main-menu' */ './preview-image.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-image.vue'),
render(data, createElement) {
return <figure class="mod-icon__image"><img src={data.image} /></figure>;
}
}

View file

@ -0,0 +1,291 @@
'use strict';
import {createElement} from 'utilities/dom';
import {transformPhrase} from 'src/i18n';
const VAR_REPLACE = /\{\{(.*?)(?:\|(.*?))?\}\}/g;
const process = (input, data, locale = 'en') => transformPhrase(input, data, locale, VAR_REPLACE, {});
// ============================================================================
// Open URL
// ============================================================================
export const open_url = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-link-ext'
}
}],
defaults: {
url: 'https://link.example/{{user.login}}'
},
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-url.vue'),
title: 'Open URL',
description: '%{options.url}',
tooltip(data) {
const url = process(data.options.url, data, this.i18n.locale);
return [
(<div class="tw-border-b tw-mg-b-05">{ // eslint-disable-line react/jsx-key
this.i18n.t('chat.actions.open_url', 'Open URL')
}</div>),
(<div class="tw-align-left">{ // eslint-disable-line react/jsx-key
url
}</div>)
]
},
click(event, data) {
const url = process(data.options.url, data, this.i18n.locale);
const win = window.open();
win.opener = null;
win.location = url;
}
};
// ============================================================================
// Chat
// ============================================================================
export const chat = {
presets: [{
appearance: {
type: 'text',
text: 'C'
}
}],
defaults: {
command: '@{{user.login}} HeyGuys'
},
title: 'Chat Command',
description: '%{options.command}',
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-chat.vue'),
process(data) {
return transformPhrase(
data.options.command,
data,
this.i18n.locale,
VAR_REPLACE,
{}
)
},
tooltip(data) {
const msg = process(data.options.command, data, this.i18n.locale);
return [
(<div class="tw-border-b tw-mg-b-05">{ // eslint-disable-line react/jsx-key
this.i18n.t('chat.actions.chat', 'Chat Command')
}</div>),
(<div class="tw-align-left">{ // eslint-disable-line react/jsx-key
msg
}</div>)
]
},
click(event, data) {
const msg = data.definition.process.call(this, data);
this.sendMessage(data.room.login, msg);
}
}
// ============================================================================
// Timeout
// ============================================================================
export const ban = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-block'
},
display: {
mod: true,
mod_icons: true
}
}],
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-ban.vue'),
title: 'Ban User',
tooltip(data) {
return this.i18n.t('chat.actions.ban', 'Ban %{user.login}', {user: data.user});
},
click(event, data) {
this.sendMessage(data.room.login, `/ban ${data.user.login} ${data.reason||''}`);
}
}
export const timeout = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-clock'
},
display: {
mod: true,
mod_icons: true
}
}],
defaults: {
duration: 600
},
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-timeout.vue'),
title: 'Timeout User',
description: '%{options.duration} second%{options.duration|en_plural}',
tooltip(data) {
return this.i18n.t(
'chat.actions.timeout',
'Timeout %{user.login} for %{duration} second%{duration|en_plural}',
{
user: data.user,
duration: data.options.duration
}
);
},
click(event, data) {
this.sendMessage(data.room.login, `/timeout ${data.user.login} ${data.options.duration} ${data.reason||''}`);
}
}
export const unban = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-ok'
},
display: {
mod: true,
mod_icons: true
}
}],
title: 'Unban User',
tooltip(data) {
return this.i18n.t('chat.actions.unban', 'Unban %{user.login}', {user: data.user});
},
click(event, data) {
this.sendMessage(data.room.login, `/unban ${data.user.login}`);
}
}
export const untimeout = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-ok'
},
display: {
mod: true,
mod_icons: true
}
}],
title: 'Untimeout User',
tooltip(data) {
return this.i18n.t('chat.actions.untimeout', 'Untimeout %{user.login}', {user: data.user});
},
click(event, data) {
this.sendMessage(data.room.login, `/untimeout ${data.user.login}`);
}
}
// ============================================================================
// Whisper
// ============================================================================
export const whisper = {
presets: [{
appearance: {
type: 'text',
text: 'W'
}
}],
title: 'Whisper User',
tooltip(data) {
return this.i18n.t('chat.actions.whisper', 'Whisper %{user.login}', data);
},
click(event, data) {
const site = this.resolve('site'),
me = site && site.getUser(),
store = site && site.store;
if ( ! me || ! store || ! data.user.id || me.id == data.user.id )
return;
const id_1 = parseInt(me.id, 10),
id_2 = parseInt(data.user.id, 10),
thread = id_1 < id_2 ? `${id_1}_${id_2}` : `${id_2}_${id_1}`;
store.dispatch({
type: 'whispers.THREAD_OPENED',
data: {
threadID: thread,
collapsed: false
}
});
}
}
// ============================================================================
// Gift Subscription
// ============================================================================
/*export const gift_sub = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-gift'
}
}],
title: 'Gift Subscription',
tooltip(data) {
return this.i18n.t('chat.actions.gift_sub', 'Gift a Sub to %{user.login}', data);
},
context() {
return (<div class="tw-border">
Woop woop.
</div>);
}
}*/

View file

@ -6,6 +6,7 @@
import Module from 'utilities/module';
import {SERVER} from 'utilities/constants';
import {has} from 'utilities/object';
import splitter from 'emoji-regex/es2015/index';
@ -140,7 +141,7 @@ export default class Emoji extends Module {
if ( ! style )
style = this.parent.context.get('chat.emoji.style');
if ( style === 0 )
if ( ! has(SIZES, style) )
style = 'twitter';
return `${SERVER}/static/emoji/img-${style}-${SIZES[style][0]}/${image}`;
@ -150,7 +151,7 @@ export default class Emoji extends Module {
if ( ! style )
style = this.parent.context.get('chat.emoji.style');
if ( style === 0 )
if ( ! has(SIZES, style) )
style = 'twitter';
return SIZES[style].map(w =>

View file

@ -17,6 +17,8 @@ import User from './user';
import * as TOKENIZERS from './tokenizers';
import * as RICH_PROVIDERS from './rich_providers';
import Actions from './actions';
export default class Chat extends Module {
constructor(...args) {
@ -33,6 +35,7 @@ export default class Chat extends Module {
this.inject(Badges);
this.inject(Emotes);
this.inject(Emoji);
this.inject(Actions);
this._link_info = {};

View file

@ -206,7 +206,7 @@ export const Mentions = {
let regex, login, display;
if ( user && user.login ) {
login = user.login.toLowerCase();
display = user.display && user.display.toLowerCase();
display = user.displayName && user.displayName.toLowerCase();
if ( display === login )
display = null;

View file

@ -0,0 +1,382 @@
<template lang="html">
<div class="ffz--action tw-elevation-1 tw-c-background tw-border tw-pd-y-05 tw-pd-r-1 tw-mg-y-05 tw-flex tw-flex-nowrap">
<div class="tw-flex tw-flex-shrink-0 tw-align-items-start handle tw-pd-x-05 tw-pd-t-1 tw-pd-b-05">
<span class="ffz-i-ellipsis-vert" />
</div>
<div class="tw-flex-grow-1">
<template v-if="! editing">
<h4>{{ title }}</h4>
<div class="description">{{ description }}</div>
<div v-if="canEdit" class="visibility tw-c-text-alt">
{{ t('setting.actions.visible', 'visible: %{list}', {list: visibility}) }}
</div>
</template>
<template v-else>
<section>
<h5>{{ t('setting.actions.appearance', 'Appearance') }}</h5>
<div class="tw-flex tw-align-items-center">
<label for="tooltip">
{{ t('setting.actions.tooltip', 'Custom Tooltip') }}
</label>
<input
v-model="edit_data.appearance.tooltip"
class="tw-mg-y-05 tw-input tw-display-inline"
>
</div>
<div class="tw-flex tw-align-items-center">
<label for="renderer_type">
{{ t('setting.actions.type', 'Type') }}
</label>
<select
id="renderer_type"
ref="renderer_type"
v-model="edit_data.appearance.type"
class="tw-mg-y-05 tw-select tw-display-inline"
>
<option
v-for="(r, key) in data.renderers"
:key="key"
:value="key"
>
{{ r.title_i18n ? t(r.title_i18n, r.title, r) : r.title }}
</option>
</select>
</div>
<div v-if="renderer && renderer.colored" class="tw-flex tw-align-items-start">
<label for="color" class="tw-mg-y-05">
{{ t('setting.actions.color', 'Color') }}
</label>
<div class="tw-full-width">
<color-picker v-model="edit_data.appearance.color" :nullable="true" />
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.color.warn', 'This value will be automatically adjusted for visibility based on your chat color settings.') }}
</div>
</div>
</div>
<component
v-if="renderer"
:is="renderer.editor"
v-model="edit_data.appearance"
/>
</section>
<section class="tw-mg-t-1 tw-border-t tw-pd-t-1">
<h5>{{ t('setting.actions.visibility', 'Visibility') }}</h5>
<div class="tw-flex tw-align-items-center">
<label for="vis_mod">
{{ t('setting.actions.edit-visible.mod', 'Moderator') }}
</label>
<select
id="vis_mod"
v-model="edit_data.display.mod"
class="tw-mg-y-05 tw-select tw-display-inline"
>
<option :value="undefined" selected>{{ t('setting.unset', 'Unset') }}</option>
<option :value="true">{{ t('setting.true', 'True') }}</option>
<option :value="false">{{ t('setting.false', 'False') }}</option>
</select>
</div>
<div class="tw-flex tw-align-items-center">
<label for="vis_mod_icons">
{{ t('setting.actions.edit-visible.mod-icons', 'Mod Icons') }}
</label>
<select
id="vis_mod_icons"
v-model="edit_data.display.mod_icons"
class="tw-mg-y-05 tw-select tw-display-inline"
>
<option :value="undefined" selected>{{ t('setting.unset', 'Unset') }}</option>
<option :value="true">{{ t('setting.visible', 'Visible') }}</option>
<option :value="false">{{ t('setting.hidden', 'Hidden') }}</option>
</select>
</div>
<div class="tw-flex tw-align-items-center">
<label for="vis_deleted">
{{ t('setting.actions.edit-visible.deleted', 'Message Deleted') }}
</label>
<select
id="vis_deleted"
v-model="edit_data.display.deleted"
class="tw-mg-y-05 tw-select tw-display-inline"
>
<option :value="undefined" selected>{{ t('setting.unset', 'Unset') }}</option>
<option :value="true">{{ t('setting.true', 'True') }}</option>
<option :value="false">{{ t('setting.false', 'False') }}</option>
</select>
</div>
</section>
<section class="tw-mg-t-1 tw-border-t tw-pd-t-1">
<h5>{{ t('setting.actions.action', 'Action') }}</h5>
<div class="tw-flex tw-align-items-center">
<label for="action_type">
{{ t('setting.actions.type', 'Type') }}
</label>
<select
id="action_type"
v-model="edit_data.action"
class="tw-mg-y-05 tw-select tw-display-inline"
>
<option
v-for="(a, key) in data.actions"
:key="key"
:value="key"
>
{{ a.title_i18n ? t(a.title_i18n, a.title, a) : a.title }}
</option>
</select>
</div>
<component
v-if="action_def && action_def.editor"
:is="action_def.editor"
:value="edit_data.options"
:defaults="action_def.defaults"
:vars="vars"
@input="onChangeAction($event)"
/>
</section>
</template>
</div>
<div v-if="canPreview" class="tw-mg-l-1 tw-border-l tw-pd-l-1 tw-pd-y-05 tw-flex tw-flex-shrink-0 tw-align-items-start">
<action-preview
:act="display"
:color="display.appearance && data.color(display.appearance.color)"
:renderers="data.renderers"
/>
</div>
<div class="tw-mg-l-1 tw-border-l tw-pd-l-1 tw-flex tw-flex-wrap tw-flex-column tw-justify-content-start tw-align-items-start">
<template v-if="editing">
<button class="tw-button tw-button--text" @click="save">
<span class="tw-button__text ffz-i-floppy">
{{ t('setting.save', 'Save') }}
</span>
</button>
<button class="tw-button tw-button--text" @click="cancel">
<span class="tw-button__text ffz-i-cancel">
{{ t('setting.cancel', 'Cancel') }}
</span>
</button>
</template>
<template v-else>
<button
v-if="canEdit"
class="tw-button tw-button--text"
@click="edit"
>
<span class="tw-button__text ffz-i-cog">
{{ t('setting.edit', 'Edit') }}
</span>
</button>
<button class="tw-button tw-button--text" @click="$emit('remove', action)">
<span class="tw-button__text ffz-i-trash">
{{ t('setting.delete', 'Delete') }}
</span>
</button>
</template>
</div>
</div>
</template>
<script>
import {has, maybe_call, deep_copy} from 'utilities/object';
export default {
props: ['action', 'data', 'inline'],
data() {
return {
editing: false,
edit_data: null
}
},
computed: {
display() {
if ( this.editing )
return this.edit_data;
return this.action.v;
},
vars() {
const out = ['user.login', 'user.displayName', 'user.id', 'user.type'];
out.push('room.login')
out.push('room.id');
if ( this.inline )
out.push('message_id');
return out.map(x => `{{${x}}}`).join(', ');
},
renderer() {
return this.canPreview && this.data.renderers[this.display.appearance.type];
},
action_def() {
return this.display && this.data.actions[this.display.action];
},
canEdit() {
return this.action.v != null;
},
canPreview() {
return this.display && this.display.appearance;
},
title() {
if ( this.action.t === 'inherit' )
return this.t('setting.inheritance', 'Inheritance Point');
else if ( ! this.display )
return this.t('setting.unknown', 'Unknown Value');
const def = this.data.actions[this.display.action];
if ( ! def )
return this.t('setting.actions.unknown', 'Unknown Action Type: %{action}', this.display);
if ( def.title ) {
const data = this.getData(),
out = maybe_call(def.title, this, data, def),
i18n = def.title_i18n || `chat.actions.${this.display.action}`;
if ( out )
return this.t(i18n, out, data);
}
return this.t('setting.actions.untitled', 'Action: %{action}', this.display);
},
description() {
if ( this.action.t === 'inherit' )
return this.t('setting.inheritance.desc', 'Inherit values from lower priority profiles at this position.');
const def = this.display && this.data.actions[this.display.action];
if ( ! def || ! def.description )
return;
const data = this.getData(),
out = maybe_call(def.description, this, data, def),
i18n = def.description_i18n || `chat.actions.${this.display.action}.desc`;
if ( out )
return this.t(i18n, out, data);
},
visibility() {
if ( ! this.display || ! this.display.appearance )
return;
const disp = this.display.display, out = [];
if ( ! disp )
return this.t('setting.actions.visible.always', 'always');
if ( disp.disable )
return this.t('setting.actions.visible.never', 'never');
if ( disp.mod === true )
out.push(this.t('setting.actions.visible.mod', 'when moderator'));
else if ( disp.mod === false )
out.push(this.t('setting.actions.visible.unmod', 'when not moderator'));
if ( disp.mod_icons === true )
out.push(this.t('setting.actions.visible.mod_icons', 'when mod icons are shown'));
else if ( disp.mod_icons === false )
out.push(this.t('setting.actions.visible.mod_icons_off', 'when mod icons are hidden'));
if ( disp.staff === true )
out.push(this.t('setting.actions.visible.staff', 'when staff'));
else if ( disp.staff === false )
out.push(this.t('setting.actions.visible.unstaff', 'when not staff'));
if ( disp.deleted === true )
out.push(this.t('setting.actions.visible.deleted', 'if message deleted'));
else if ( disp.deleted === false )
out.push(this.t('setting.actions.visible.undeleted', 'if message not deleted'));
return out.join(', ');
}
},
methods: {
onChangeAction(val) {
for(const key in val)
if ( has(val, key) ) {
const v = val[key];
if ( typeof v === 'string' && ! v.length )
delete val[key];
}
this.edit_data.options = val;
},
edit() {
if ( ! this.canEdit )
return;
this.editing = true;
this.edit_data = deep_copy(this.action.v);
if ( ! this.edit_data.options )
this.edit_data.options = {};
if ( ! this.edit_data.display )
this.edit_data.display = {};
if ( ! this.edit_data.appearance )
this.edit_data.appearance = {};
},
save() {
this.$emit('save', this.edit_data);
this.cancel();
},
cancel() {
this.editing = false;
this.edit_data = null;
},
getData() {
const def = this.display && this.data.actions[this.display.action];
if ( ! def )
return;
return Object.assign({}, this.display, {
options: Object.assign({}, def && def.defaults, this.display.options)
})
}
}
}
</script>

View file

@ -0,0 +1,31 @@
<template lang="html">
<div
:data-action="act.action"
:data-options="act.options ? JSON.stringify(act.options) : null"
:data-tip="act.appearance.tooltip"
:class="{'ffz-tooltip': tooltip, 'tw-pd-05': pad, 'colored': color && color.length > 0}"
data-tooltip-type="action"
class="ffz-mod-icon mod-icon tw-c-text-alt-2 tw-font-size-4"
>
<component
v-if="renderer && renderer.preview"
:is="renderer.preview"
:data="act.appearance"
:color="color"
/>
</div>
</template>
<script>
export default {
props: ['act', 'color', 'tooltip', 'pad', 'renderers'],
computed: {
renderer() {
return this.renderers[this.act.appearance.type]
}
}
}
</script>

View file

@ -5,7 +5,7 @@
class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1"
>
<span class="ffz-i-info" />
{{ t('setting.badge-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 class="tw-mg-b-2 tw-align-right">

View file

@ -0,0 +1,428 @@
<template lang="html">
<div class="ffz--widget ffz--chat-actions tw-border-t tw-pd-y-1">
<div
v-if="source && source !== profile"
class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1"
>
<span class="ffz-i-info" />
{{ t('setting.warn-inheritence', 'These values are being overridden by another profile and may not take effect.') }}
</div>
<div class="tw-pd-b-1 tw-border-b tw-mg-b-1">
<div class="tw-flex tw-align-items-center ffz--inline">
{{ t('setting.actions.preview', 'Preview:') }}
<div class="tw-pd-x-1">
<input
id="as_mod"
ref="as_mod"
:checked="is_moderator"
type="checkbox"
class="tw-checkbox__input"
@change="onPreview"
>
<label for="as_mod" class="tw-checkbox__label">
{{ t('setting.actions.preview.mod', 'As Moderator') }}
</label>
</div>
<div class="tw-pd-x-1">
<input
id="is_deleted"
ref="is_deleted"
:checked="is_deleted"
type="checkbox"
class="tw-checkbox__input"
@change="onPreview"
>
<label for="is_deleted" class="tw-checkbox__label">
{{ t('setting.actions.preview.deleted', 'Deleted Message') }}
</label>
</div>
<div class="tw-pd-x-1">
<input
id="with_mod_icons"
ref="with_mod_icons"
:checked="with_mod_icons"
type="checkbox"
class="tw-checkbox__input"
@change="onPreview"
>
<label for="with_mod_icons" class="tw-checkbox__label">
{{ t('setting.actions.preview.mod_icons', 'With Mod Icons') }}
</label>
</div>
<div class="tw-pd-x-1">
<input
id="show_all"
ref="show_all"
:checked="show_all"
type="checkbox"
class="tw-checkbox__input"
@change="onPreview"
>
<label for="show_all" class="tw-checkbox__label">
{{ t('setting.actions.preview.all', 'Show All') }}
</label>
</div>
</div>
<div
:data-user="JSON.stringify(sample_user)"
:data-room="JSON.stringify(sample_room)"
class="tw-flex tw-align-items-center tw-justify-content-center tw-pd-t-1"
data-msg-id="1234-5678"
>
<div
v-if="! display.length"
class="tw-c-text-alt-2 tw-pd-05 tw-font-size-4"
>
{{ t('setting.actions.no-visible', 'no visible actions') }}
</div>
<action-preview
v-for="act in display"
:key="act.id"
:act="act.v"
:color="color(act.v.appearance.color)"
:renderers="data.renderers"
tooltip="true"
pad="true"
/>
</div>
</div>
<div class="tw-flex tw-align-items-center tw-pd-b-05">
<div class="tw-flex-grow-1">
{{ t('setting.actions.drag', 'Drag actions to re-order them.') }}
</div>
<div
v-on-clickaway="closeAdd"
class="tw-relative"
>
<button
class="tw-mg-l-1 tw-button tw-button--text"
@click="toggleAdd"
>
<span class="tw-button__text ffz-i-plus">
{{ t('setting.actions.new', 'New...') }}
</span>
<span class="tw-button__icon tw-button__icon--right">
<figure class="ffz-i-down-dir" />
</span>
</button>
<balloon
v-if="add_open"
color="background-alt-2"
dir="down-right"
size="sm"
>
<simplebar classes="language-select-menu__balloon">
<div class="tw-pd-y-1">
<template v-for="(preset, idx) in presets">
<div
v-if="preset.divider"
:key="idx"
class="tw-mg-1 tw-border-b"
/>
<button
v-else
:key="idx"
:disabled="preset.disabled"
class="tw-interactable"
@click="add(preset.value)"
>
<div class="tw-flex tw-align-items-center tw-pd-y-05 tw-pd-x-1">
<div class="tw-flex-grow-1 tw-mg-r-1">
{{ t(preset.title_i18n, preset.title, preset) }}
</div>
<action-preview v-if="preset.appearance" :act="preset" :renderers="data.renderers" />
</div>
</button>
</template>
</div>
</simplebar>
</balloon>
</div>
<button
v-if="val.length"
class="tw-mg-l-1 tw-button tw-button--text tw-tooltip-wrapper"
@click="clear"
>
<span class="tw-button__text ffz-i-trash">
{{ t('setting.delete-all', 'Delete All') }}
</span>
<span class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.actions.delete-all', "Delete all of this profile's actions.") }}
</span>
</button>
<button
v-if="! val.length"
class="tw-mg-l-1 tw-button tw-button--text tw-tooltip-wrapper"
@click="populate"
>
<span class="tw-button__text ffz-i-trash">
{{ t('setting.actions.add-default', 'Add Defaults') }}
</span>
<span class="tw-tooltip tw-tooltip--down tw-tooltip--align-right">
{{ t('setting.actions.add-default-tip', 'Add all of the default actions to this profile.') }}
</span>
</button>
</div>
<div ref="list" class="ffz--action-list">
<div v-if="! val.length" class="tw-c-text-alt-2 tw-font-size-4 tw-align-center tw-c-text-alt-2 tw-pd-1">
{{ t('setting.actions.no-actions', 'no actions are defined in this profile') }}
</div>
<section v-for="act in val" :key="act.id">
<action-editor
:action="act"
:data="data"
:inline="item.inline"
@remove="remove(act)"
@save="save(act, $event)"
/>
</section>
</div>
</div>
</template>
<script>
import SettingMixin from '../setting-mixin';
import Sortable from 'sortablejs';
import {deep_copy} from 'utilities/object';
import {mixin as clickaway} from 'vue-clickaway';
let last_id = 0;
export default {
mixins: [clickaway, SettingMixin],
props: ['item', 'context'],
data() {
return {
is_moderator: true,
with_mod_icons: true,
is_staff: false,
is_deleted: false,
show_all: false,
add_open: false,
sample_user: {
displayName: 'SirStendec',
login: 'sirstendec',
id: 49399878
},
sample_room: {
displayName: 'FrankerFaceZ',
login: 'frankerfacez',
id: 46622312
}
}
},
computed: {
hasInheritance() {
for(const val of this.val)
if ( val.t === 'inherit' )
return true;
},
presets() {
const out = [];
out.push({
disabled: this.hasInheritance,
title: 'Inheritance Point',
title_i18n: 'setting.inheritance',
value: {t: 'inherit'}
});
if ( ! this.item.inline ) {
out.push({
title: 'New Line',
value: {
v: {type: 'new-line'}
}
});
out.push({
title: 'Space (Small)',
value: {
v: {type: 'space-small'}
}
});
out.push({
title: 'Space (Expanding)',
value: {
v: {type: 'space'}
}
});
}
out.push({divider: true});
for(const key in this.data.actions) { // eslint-disable-line guard-for-in
const act = this.data.actions[key];
if ( act && act.presets )
for(const preset of act.presets) {
if ( typeof act.title !== 'string' && ! preset.title )
continue;
out.push(Object.assign({
action: key,
title: act.title,
title_i18n: act.title_i18n || `chat.actions.${key}`,
value: {
v: Object.assign({
action: key
}, preset)
}
}, preset));
}
}
return out;
},
display() {
const out = [];
if ( this.val )
for(const val of this.val) {
if ( val.v && this.displayAction(val.v) )
out.push(val);
}
return out;
},
val() {
if ( ! this.has_value )
return [];
return this.value.map(x => {
x.id = x.id || `${Date.now()}-${Math.random()}-${last_id++}`;
return x;
});
}
},
mounted() {
this._sortable = Sortable.create(this.$refs.list, {
draggable: 'section',
filter: 'button',
onUpdate: event => {
if ( event.newIndex === event.oldIndex )
return;
const new_val = Array.from(this.val);
new_val.splice(event.newIndex, 0, ...new_val.splice(event.oldIndex, 1));
this.set(new_val);
}
});
},
beforeDestroy() {
if ( this._sortable )
this._sortable.destroy();
this._sortable = null;
},
methods: {
closeAdd() {
this.add_open = false;
},
toggleAdd() {
this.add_open = ! this.add_open;
},
populate() {
this.set(deep_copy(this.default_value));
},
add(val) {
const vals = Array.from(this.val);
vals.push(val);
this.set(deep_copy(vals));
this.add_open = false;
},
remove(val) {
const vals = Array.from(this.val),
idx = vals.indexOf(val);
if ( idx !== -1 ) {
vals.splice(idx, 1);
if ( vals.length )
this.set(vals);
else
this.clear();
}
},
save(val, new_val) {
val.v = new_val;
this.set(deep_copy(this.val));
},
onPreview() {
this.show_all = this.$refs.show_all.checked;
this.is_moderator = this.$refs.as_mod.checked;
this.is_staff = false; //this.$refs.as_staff.checked;
this.with_mod_icons = this.$refs.with_mod_icons.checked;
this.is_deleted = this.$refs.is_deleted.checked;
},
displayAction(action) {
if ( ! action.appearance )
return false;
const disp = action.display;
if ( ! disp || this.show_all )
return true;
if ( disp.disable )
return false;
if ( disp.mod != null && disp.mod !== this.is_moderator )
return false;
if ( disp.mod_icons != null && disp.mod !== this.with_mod_icons )
return false;
if ( disp.staff != null && disp.mod !== this.is_staff )
return false;
if ( disp.deleted != null && disp.deleted !== this.is_deleted )
return false;
return true;
},
color(input) {
if ( ! input )
return input;
return this.data.color(input)
}
}
}
</script>

View file

@ -0,0 +1,106 @@
<template lang="html">
<div class="ffz--color-widget tw-relative tw-full-width tw-mg-y-05">
<input
ref="input"
v-bind="$attrs"
v-model="color"
type="text"
class="tw-input tw-pd-r-3"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
@input="onChange"
>
<button
class="ffz-color-preview tw-absolute tw-top-0 tw-bottom-0 tw-right-0 tw-border-l tw-z-default"
@click="togglePicker"
>
<figure v-if="color" :style="`background-color: ${color}`" />
<figure v-else class="ffz-i-eyedropper" />
</button>
<div v-on-clickaway="closePicker" v-if="open" class="tw-absolute tw-z-default tw-right-0">
<chrome-picker :value="colors" @input="onPick" />
</div>
</div>
</template>
<script>
import {Color} from 'utilities/color';
import {Chrome} from 'vue-color';
import {mixin as clickaway} from 'vue-clickaway';
export default {
components: {
'chrome-picker': Chrome
},
mixins: [clickaway],
props: {
value: String,
default: {
type: String,
default: '#000'
}
},
data() {
return {
color: '',
open: false
}
},
computed: {
colors() {
return Color.RGBA.fromCSS(this.color || this.default)
}
},
watch: {
value(val) {
this.color = val;
}
},
mounted() {
this.color = this.value;
},
methods: {
openPicker() {
this.open = true;
},
closePicker() {
this.open = false;
},
togglePicker() {
this.open = ! this.open;
},
onPick(color) {
const old_val = this.color;
if ( color.rgba.a == 1 )
this.color = color.hex;
else {
const c = color.rgba;
this.color = `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a})`;
}
if ( old_val !== this.color )
this.$emit('input', this.color);
},
onChange() {
this.$emit('input', this.color);
}
}
}
</script>

View file

@ -1,25 +1,25 @@
<template lang="html">
<div class="ffz--experiments tw-pd-t-05">
<div class="tw-pd-b-1 tw-mg-b-1 tw-border-b">
{{ t('settings.experiments.about', 'This feature allows you to override experiment values. Please note that, for most experiments, you may have to refresh the page for your changes to take effect.') }}
{{ t('setting.experiments.about', 'This feature allows you to override experiment values. Please note that, for most experiments, you may have to refresh the page for your changes to take effect.') }}
</div>
<div class="tw-mg-b-2 tw-flex tw-align-items-center">
<div class="tw-flex-grow-1">
{{ t('settings.experiments.unique-id', 'Unique ID: %{id}', {id: unique_id}) }}
{{ t('setting.experiments.unique-id', 'Unique ID: %{id}', {id: unique_id}) }}
</div>
<select
ref="sort_select"
class="tw-mg-x-05 tw-select tw-display-line tw-width-auto"
@change="onSort"
>
<option :selected="sort_by === 0">{{ t('settings.experiments.sort-name', 'Sort By: Name') }}</option>
<option :selected="sort_by === 1">{{ t('settings.experiments.sort-rarity', 'Sort By: Rarity') }}</option>
<option :selected="sort_by === 0">{{ t('setting.experiments.sort-name', 'Sort By: Name') }}</option>
<option :selected="sort_by === 1">{{ t('setting.experiments.sort-rarity', 'Sort By: Rarity') }}</option>
</select>
</div>
<h3 class="tw-mg-b-1">
{{ t('settings.experiments.ffz', 'FrankerFaceZ Experiments') }}
{{ t('setting.experiments.ffz', 'FrankerFaceZ Experiments') }}
</h3>
<div class="ffz--experiment-list">
@ -48,7 +48,7 @@
:key="idx"
:selected="i.value === exp.value"
>
{{ t('settings.experiments.entry', '%{value} (weight: %{weight})', i) }}
{{ t('setting.experiments.entry', '%{value} (weight: %{weight})', i) }}
</option>
</select>
@ -67,12 +67,12 @@
</div>
</section>
<div v-if="! Object.keys(ffz_data).length">
{{ t('settings.experiments.none', 'There are no current experiments.') }}
{{ t('setting.experiments.none', 'There are no current experiments.') }}
</div>
</div>
<h3 class="tw-mg-t-5 tw-mg-b-1">
{{ t('settings.experiments.twitch', 'Twitch Experiments') }}
{{ t('setting.experiments.twitch', 'Twitch Experiments') }}
</h3>
<div class="ffz--experiment-list">
@ -115,7 +115,7 @@
v-if="exp.in_use === false"
:selected="exp.default"
>
{{ t('settings.experiments.unset', 'unset') }}
{{ t('setting.experiments.unset', 'unset') }}
</option>
<option
v-for="(i, idx) in exp.groups"
@ -141,7 +141,7 @@
</div>
</section>
<div v-if="! Object.keys(twitch_data).length">
{{ t('settings.experiments.none', 'There are no current experiments.') }}
{{ t('setting.experiments.none', 'There are no current experiments.') }}
</div>
</div>
</div>

View file

@ -38,18 +38,14 @@
/>
</header>
<div class="tw-full-width tw-full-height tw-overflow-hidden tw-flex tw-flex-nowrap tw-relative">
<div class="ffz-vertical-nav__items tw-full-width tw-flex-grow-1 scrollable-area" data-simplebar>
<div class="simplebar-scroll-content">
<div class="simplebar-content">
<menu-tree
:current-item="currentItem"
:modal="nav"
@change-item="changeItem"
@navigate="navigate"
/>
</div>
</div>
</div>
<simplebar classes="ffz-vertical-nav__items tw-full-width tw-flex-grow-1">
<menu-tree
:current-item="currentItem"
:modal="nav"
@change-item="changeItem"
@navigate="navigate"
/>
</simplebar>
</div>
<footer class="tw-c-text-alt tw-border-t tw-pd-1">
<div>
@ -60,20 +56,16 @@
</div>
</footer>
</nav>
<main class="tw-flex-grow-1 scrollable-area" data-simplebar>
<div class="simplebar-scroll-content">
<div class="simplebar-content">
<menu-page
v-if="currentItem"
ref="page"
:context="context"
:item="currentItem"
@change-item="changeItem"
@navigate="navigate"
/>
</div>
</div>
</main>
<simplebar classes="tw-flex-grow-1">
<menu-page
v-if="currentItem"
ref="page"
:context="context"
:item="currentItem"
@change-item="changeItem"
@navigate="navigate"
/>
</simplebar>
</section>
</div>
</template>

View file

@ -7,7 +7,7 @@
@click="save"
>
<span class="tw-button__text ffz-i-floppy">
{{ t('settings.profiles.save', 'Save') }}
{{ t('setting.save', 'Save') }}
</span>
</button>
<button
@ -17,24 +17,24 @@
@click="del"
>
<span class="tw-button__text ffz-i-trash">
{{ t('setting.profiles.delete', 'Delete') }}
{{ t('setting.delete', 'Delete') }}
</span>
</button>
<!--button class="tw-mg-l-1 tw-button tw-button--text">
<span class="tw-button__text ffz-i-download">
{{ t('setting.profiles.export', 'Export') }}
{{ t('setting.export', 'Export') }}
</span>
</button-->
</div>
<div class="ffz--menu-container tw-border-t">
<header>
{{ t('settings.data_management.profiles.edit.general', 'General') }}
{{ t('setting.data_management.profiles.edit.general', 'General') }}
</header>
<div class="ffz--widget tw-flex tw-flex-nowrap">
<label for="ffz:editor:name">
{{ t('settings.data_management.profiles.edit.name', 'Name') }}
{{ t('setting.data_management.profiles.edit.name', 'Name') }}
</label>
<input
@ -47,7 +47,7 @@
<div class="ffz--widget tw-flex tw-flex-nowrap">
<label for="ffz:editor:description">
{{ t('settings.data_management.profiles.edit.desc', 'Description') }}
{{ t('setting.data_management.profiles.edit.desc', 'Description') }}
</label>
<textarea
@ -61,10 +61,10 @@
<div class="ffz--menu-container tw-border-t">
<header>
{{ t('settings.data_management.profiles.edit.rules', 'Rules') }}
{{ t('setting.data_management.profiles.edit.rules', 'Rules') }}
</header>
<section class="tw-pd-b-1">
{{ t('settings.data_management.profiles.edit.rules.description',
{{ t('setting.data_management.profiles.edit.rules.description',
'Rules allows you to define a series of conditions under which this profile will be active.')
}}
</section>
@ -144,7 +144,7 @@ export default {
del() {
if ( this.item.profile || this.unsaved ) {
if ( ! confirm(this.t( // eslint-disable-line no-alert
'settings.profiles.warn-delete',
'setting.profiles.warn-delete',
'Are you sure you wish to delete this profile? It cannot be undone.'
)) )
return
@ -192,7 +192,7 @@ export default {
if ( this.unsaved )
return confirm( // eslint-disable-line no-alert
this.t(
'settings.warn-unsaved',
'setting.warn-unsaved',
'You have unsaved changes. Are you sure you want to leave the editor?'
));
}

View file

@ -18,7 +18,7 @@
</button>
<!--button class="tw-mg-l-1 tw-button tw-button--text">
<span class="tw-button__text ffz-i-upload">
{{ t('setting.profiles.import', 'Import…') }}
{{ t('setting.import', 'Import…') }}
</span>
</button-->
</div>
@ -48,7 +48,7 @@
<div class="tw-flex tw-flex-shrink-0 tw-align-items-center">
<button class="tw-button tw-button--text" disabled @notclick="edit(p)">
<span class="tw-button__text ffz-i-cog">
{{ t('setting.profiles.configure', 'Configure') }}
{{ t('setting.configure', 'Configure') }}
</span>
</button>
</div>

View file

@ -346,7 +346,8 @@ export default class Metadata extends Module {
tooltipClass: 'ffz-metadata-balloon tw-balloon tw-block tw-border tw-elevation-1 tw-border-radius-small tw-c-background',
// Hide the arrow for now, until we re-do our CSS to make it render correctly.
arrowClass: 'tw-balloon__tail tw-overflow-hidden tw-absolute tw-hidden',
arrowClass: 'tw-balloon__tail tw-overflow-hidden tw-absolute',
arrowInner: 'tw-balloon__tail-symbol tw-border-t tw-border-r tw-border-b tw-border-l tw-border-radius-small tw-c-background tw-absolute',
innerClass: 'tw-pd-1',
popper: {

View file

@ -38,7 +38,7 @@ export default class TooltipProvider extends Module {
const container = document.querySelector('.twilight-root,.twilight-minimal-root') || document.body,
is_minimal = container && container.classList.contains('twilight-minimal-root');
this.tips = new Tooltip(is_minimal ? '.twilight-minimal-root' : 'body #root', 'ffz-tooltip', {
this.tips = new Tooltip(is_minimal ? '.twilight-minimal-root,body' : 'body #root,body', 'ffz-tooltip', {
html: true,
delayHide: this.checkDelayHide.bind(this),
delayShow: this.checkDelayShow.bind(this),

View file

@ -33,7 +33,7 @@ export const object_merge = {
values.unshift(val);
}
if ( values.length )
if ( sources.length )
return [
Object.assign({}, ...values),
sources
@ -57,6 +57,8 @@ export const array_merge = {
trailing = [],
sources = [];
let had_value = false;
for(const profile of profiles)
if ( profile.has(key) ) {
const value = profile.get(key);
@ -68,6 +70,7 @@ export const array_merge = {
sources.push(profile.id);
let is_trailing = false;
for(const val of value) {
had_value = true;
if ( val.t === 'inherit' )
is_trailing = true;
else if ( is_trailing )
@ -81,7 +84,7 @@ export const array_merge = {
break;
}
if ( values.length || trailing.length )
if ( had_value )
return [
values.concat(trailing),
sources

View file

@ -12,6 +12,7 @@ import FineRouter from 'utilities/compat/fine-router';
import Apollo from 'utilities/compat/apollo';
import {createElement} from 'utilities/dom';
import {has} from 'utilities/object';
import MAIN_URL from 'site/styles/main.scss';
@ -59,7 +60,8 @@ export default class Twilight extends BaseSite {
document.head.appendChild(createElement('link', {
href: MAIN_URL,
rel: 'stylesheet',
type: 'text/css'
type: 'text/css',
crossOrigin: 'anonymouse'
}));
// Check for ?ffz-settings in page and open the
@ -93,8 +95,11 @@ export default class Twilight extends BaseSite {
}
getUser() {
if ( this._user )
return this._user;
const session = this.getSession();
return session && session.user;
return this._user = session && session.user;
}
getCore() {
@ -119,7 +124,7 @@ Twilight.KNOWN_MODULES = {
'core-2': n => n.p && n.p.experiments,
cookie: n => n && n.set && n.get && n.getJSON && n.withConverter,
'extension-service': n => n.extensionService,
'chat-types': n => n.a && n.a.PostWithMention,
'chat-types': n => n.b && has(n.b, 'Message') && has(n.b, 'RoomMods'),
'gql-printer': n => n !== window && n.print
}

View file

@ -0,0 +1,81 @@
'use strict';
// ============================================================================
// Channel
// ============================================================================
import Module from 'utilities/module';
export default class Channel extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('settings');
this.inject('site.fine');
this.settings.add('channel.hosting.enable', {
default: true,
ui: {
path: 'Channel > Behavior >> Hosting',
title: 'Enable Channel Hosting',
component: 'setting-check-box'
},
changed: val => this.updateChannelHosting(val)
});
this.ChannelPage = this.fine.define(
'channel-page',
n => n.handleHostingChange,
['user']
);
}
onEnable() {
this.ChannelPage.on('mount', this.wrapChannelPage, this);
this.ChannelPage.ready((cls, instances) => {
for(const inst of instances)
this.wrapChannelPage(inst);
});
}
wrapChannelPage(inst) {
if ( inst._ffz_hosting_wrapped )
return;
const t = this;
inst._ffz_hosting_wrapped = true;
inst.ffzOldHostHandler = inst.handleHostingChange;
inst.handleHostingChange = function(channel) {
inst.ffzExpectedHost = channel;
if ( t.settings.get('channel.hosting.enable') )
return inst.ffzOldHostHandler(channel);
}
// Store the current state and disable the current host if needed.
inst.ffzExpectedHost = inst.state.isHosting ? inst.state.videoPlayerSource : null;
if ( ! this.settings.get('channel.hosting.enable') )
inst.ffzOldHostHandler(null);
// Finally, we force an update so that any child components
// receive our updated handler.
inst.forceUpdate();
}
updateChannelHosting(val) {
if ( val === undefined )
val = this.settings.get('channel.hosting.enable');
for(const inst of this.ChannelPage.instances)
inst.ffzOldHostHandler(val ? inst.ffzExpectedHost : null);
}
}

View file

@ -4,7 +4,7 @@
// Chat Hooks
// ============================================================================
import {ColorAdjuster} from 'utilities/color';
import {ColorAdjuster, Color} from 'utilities/color';
import {setChildren} from 'utilities/dom';
import {has, split_chars} from 'utilities/object';
@ -19,75 +19,79 @@ import EmoteMenu from './emote_menu';
import TabCompletion from './tab_completion';
const CHAT_TYPES = (e => {
const MESSAGE_TYPES = ((e = {}) => {
e[e.Post = 0] = 'Post';
e[e.Action = 1] = 'Action';
e[e.PostWithMention = 2] = 'PostWithMention';
e[e.Ban = 3] = 'Ban';
e[e.Timeout = 4] = 'Timeout';
e[e.AutoModRejectedPrompt = 5] = 'AutoModRejectedPrompt';
e[e.AutoModMessageRejected = 6] = 'AutoModMessageRejected';
e[e.AutoModMessageAllowed = 7] = 'AutoModMessageAllowed';
e[e.AutoModMessageDenied = 8] = 'AutoModMessageDenied';
e[e.Connected = 9] = 'Connected';
e[e.Disconnected = 10] = 'Disconnected';
e[e.Reconnect = 11] = 'Reconnect';
e[e.Hosting = 12] = 'Hosting';
e[e.Unhost = 13] = 'Unhost';
e[e.Subscription = 14] = 'Subscription';
e[e.Resubscription = 15] = 'Resubscription';
e[e.SubGift = 16] = 'SubGift';
e[e.Clear = 17] = 'Clear';
e[e.SubscriberOnlyMode = 18] = 'SubscriberOnlyMode';
e[e.FollowerOnlyMode = 19] = 'FollowerOnlyMode';
e[e.SlowMode = 20] = 'SlowMode';
e[e.RoomMods = 21] = 'RoomMods';
e[e.RoomState = 22] = 'RoomState';
e[e.Raid = 23] = 'Raid';
e[e.Unraid = 24] = 'Unraid';
e[e.Notice = 25] = 'Notice';
e[e.Info = 26] = 'Info';
e[e.BadgesUpdated = 27] = 'BadgesUpdated';
e[e.Purchase = 28] = 'Purchase';
return e;
})({});
})();
const MOD_TYPES = ((e = {}) => {
e[e.Ban = 0] = 'Ban';
e[e.Timeout = 1] = 'Timeout';
return e;
})();
const AUTOMOD_TYPES = ((e = {}) => {
e[e.MessageRejectedPrompt = 0] = 'MessageRejectedPrompt';
e[e.MessageRejected = 1] = 'MessageRejected';
e[e.MessageAllowed = 2] = 'MessageAllowed';
e[e.MessageDenied = 3] = 'MessageDenied';
return e;
})();
const CHAT_TYPES = ((e = {}) => {
e[e.Message = 0] = 'Message';
e[e.Moderation = 1] = 'Moderation';
e[e.ModerationAction = 2] = 'ModerationAction';
e[e.TargetedModerationAction = 3] = 'TargetedModerationAction';
e[e.AutoMod = 4] = 'AutoMod';
e[e.Connected = 5] = 'Connected';
e[e.Disconnected = 6] = 'Disconnected';
e[e.Reconnect = 7] = 'Reconnect';
e[e.Hosting = 8] = 'Hosting';
e[e.Unhost = 9] = 'Unhost';
e[e.Hosted = 10] = 'Hosted';
e[e.Subscription = 11] = 'Subscription';
e[e.Resubscription = 12] = 'Resubscription';
e[e.SubGift = 13] = 'SubGift';
e[e.Clear = 14] = 'Clear';
e[e.SubscriberOnlyMode = 15] = 'SubscriberOnlyMode';
e[e.FollowerOnlyMode = 16] = 'FollowerOnlyMode';
e[e.SlowMode = 17] = 'SlowMode';
e[e.EmoteOnlyMode = 18] = 'EmoteOnlyMode';
e[e.RoomMods = 19] = 'RoomMods';
e[e.RoomState = 20] = 'RoomState';
e[e.Raid = 21] = 'Raid';
e[e.Unraid = 22] = 'Unraid';
e[e.Ritual = 23] = 'Ritual';
e[e.Notice = 24] = 'Notice';
e[e.Info = 25] = 'Info';
e[e.BadgesUpdated = 26] = 'BadgesUpdated';
e[e.Purchase = 27] = 'Purchase';
e[e.BitsCharity = 28] = 'BitsCharity';
e[e.CrateGift = 29] = 'CrateGift'
return e;
})();
const NULL_TYPES = [
'Reconnect',
'RoomState',
'BadgesUpdated'
'BadgesUpdated',
'Clear'
];
const EVENTS = [
'onJoinedEvent',
'onDisconnectedEvent',
'onReconnectingEvent',
'onChatMessageEvent',
'onChatNoticeEvent',
'onChatActionEvent',
const MISBEHAVING_EVENTS = [
'onBitsCharityEvent',
//'onRitualEvent', -- handled by conversion to Message event
'onBadgesUpdatedEvent',
'onHostingEvent',
'onUnhostEvent',
'onPurchaseEvent',
'onCrateEvent',
//'onRitualEvent',
'onSubscriptionEvent',
//'onResubscriptionEvent',
'onSubscriptionGiftEvent',
'onTimeoutEvent',
'onBanEvent',
'onClearChatEvent',
'onRaidEvent',
'onUnraidEvent',
'onRoomModsEvent',
'onRoomStateEvent',
'onFollowerOnlyModeEvent',
'onSlowModeEvent',
'onSubscriberOnlyModeEvent',
'onEmoteOnlyModeEvent',
'onBitsCharityEvent'
'onCrateEvent'
];
@ -98,6 +102,7 @@ export default class ChatHook extends Module {
this.should_enable = true;
this.colors = new ColorAdjuster;
this.inverse_colors = new ColorAdjuster;
this.inject('settings');
@ -134,6 +139,12 @@ export default class ChatHook extends Module {
Twilight.CHAT_ROUTES
);
this.RoomPicker = this.fine.define(
'chat-picker',
n => n.closeRoomPicker && n.handleRoomSelect,
Twilight.CHAT_ROUTES
);
// Settings
@ -262,7 +273,8 @@ export default class ChatHook extends Module {
const is_dark = this.chat.context.get('theme.is-dark'),
mode = this.chat.context.get('chat.adjustment-mode'),
contrast = this.chat.context.get('chat.adjustment-contrast'),
c = this.colors;
c = this.colors,
ic = this.inverse_colors;
// TODO: Get the background color from the theme system.
// Updated: Use the lightest/darkest colors from alternating rows for better readibility.
@ -270,6 +282,10 @@ export default class ChatHook extends Module {
c.mode = mode;
c.contrast = contrast;
ic._base = is_dark ? '#dad8de' : '#19171c';
ic.mode = mode;
ic.contrast = is_dark ? 13 : 16;
this.updateChatLines();
}
@ -313,14 +329,20 @@ export default class ChatHook extends Module {
}
onEnable() {
this.on('site.web_munch:loaded', () => {
const ct = this.web_munch.getModule('chat-types');
this.chat_types = ct && ct.a || CHAT_TYPES;
})
grabTypes() {
const ct = this.web_munch.getModule('chat-types');
this.chat_types = ct && ct.a || CHAT_TYPES;
if ( ct ) {
this.automod_types = ct && ct.a || AUTOMOD_TYPES;
this.chat_types = ct && ct.b || CHAT_TYPES;
this.message_types = ct && ct.c || MESSAGE_TYPES;
this.mod_types = ct && ct.e || MOD_TYPES;
}
}
onEnable() {
this.on('site.web_munch:loaded', this.grabTypes);
this.grabTypes();
this.chat.context.on('changed:chat.width', this.updateChatCSS, this);
this.chat.context.on('changed:chat.font-size', this.updateChatCSS, this);
@ -447,6 +469,19 @@ export default class ChatHook extends Module {
for(const inst of instances)
this.fixPinnedCheer(inst);
});
this.RoomPicker.ready((cls, instances) => {
for(const inst of instances)
this.closeRoomPicker(inst);
});
this.RoomPicker.on('mount', this.closeRoomPicker, this);
}
closeRoomPicker(inst) { // eslint-disable-line class-methods-use-this
inst.closeRoomPicker();
}
@ -480,18 +515,52 @@ export default class ChatHook extends Module {
}
sendMessage(room, message) {
const controller = this.ChatController.first,
service = controller && controller.chatService;
if ( ! service )
return null;
if ( room.startsWith('#') )
room = room.slice(1);
if ( room.toLowerCase() !== service.channelLogin.toLowerCase() )
return null;
service.sendMessage(message);
}
wrapChatService(cls) {
const t = this,
old_handler = cls.prototype.connectHandlers;
old_handler = cls.prototype.connectHandlers,
old_send = cls.prototype.sendMessage;
cls.prototype._ffz_was_here = true;
cls.prototype.sendMessage = function(raw_msg) {
const msg = raw_msg.replace(/\n/g, '');
if ( msg.startsWith('/ffz') ) {
this.postMessage({
type: t.chat_types.Notice,
message: 'The /ffz command is not yet re-implemented.'
})
return false;
}
return old_send.call(this, msg);
}
cls.prototype.connectHandlers = function(...args) {
if ( ! this._ffz_init ) {
const i = this,
pm = this.postMessage;
const i = this;
for(const key of EVENTS) {
for(const key of MISBEHAVING_EVENTS) {
const original = this[key];
if ( original )
this[key] = function(e, t) {
@ -510,10 +579,7 @@ export default class ChatHook extends Module {
out.sub_months = e.months;
out.sub_plan = e.methods;
i._wrapped = e;
const ret = i.postMessage(out);
i._wrapped = null;
return ret;
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
@ -528,10 +594,7 @@ export default class ChatHook extends Module {
out.ffz_type = 'ritual';
out.ritual = e.type;
i._wrapped = e;
const ret = i.postMessage(out);
i._wrapped = null;
return ret;
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
t.log.capture(err, {extra: e});
@ -551,38 +614,13 @@ export default class ChatHook extends Module {
return old_unhost.call(i, e, _t);
}
const old_post = this.postMessage;
this.postMessage = function(e) {
const original = this._wrapped;
if ( original ) {
// Check that the message is relevant to this channel.
if ( original.channel && this.channelLogin && original.channel.slice(1) !== this.channelLogin.toLowerCase() )
return;
if ( original && ! e._ffz_checked )
return this.postMessageToCurrentChannel(original, e);
const c = e.channel = original.channel;
if ( c )
e.roomLogin = c.charAt(0) === '#' ? c.slice(1) : c;
if ( original.message ) {
const u = original.message.user;
if ( u )
e.emotes = u.emotes;
if ( typeof original.action === 'string' )
e.message = original.action;
else
e.message = original.message.body;
// Twitch doesn't generate a proper emote tag for echoed back
// actions, so we have to regenerate it. Fun. :D
if ( u && u.username === i.userLogin )
e.emotes = findEmotes(e.message, i.selfEmotes);
}
//e.original = original;
}
//t.log.info('postMessage', e);
return pm.call(this, e);
return old_post.call(this, e);
}
this._ffz_init = true;
@ -590,6 +628,39 @@ export default class ChatHook extends Module {
return old_handler.apply(this, ...args);
}
cls.prototype.postMessageToCurrentChannel = function(original, message) {
message._ffz_checked = true;
if ( original.channel ) {
let chan = message.channel = original.channel.toLowerCase();
if ( chan.startsWith('#') )
chan = chan.slice(1);
if ( chan !== this.channelLogin.toLowerCase() )
return;
message.roomLogin = chan;
}
if ( original.message ) {
const user = original.message.user;
if ( user )
message.emotes = user.emotes;
if ( typeof original.action === 'string' )
message.message = original.action;
else
message.message = original.message.body;
// Twitch doesn't generate a proper emote tag for echoed back
// actions, so we have to regenerate it. Fun. :D
if ( user && user.username === this.userLogin )
message.emotes = findEmotes(message.message, this.selfEmotes);
}
this.postMessage(message);
}
}

View file

@ -6,7 +6,6 @@
import Twilight from 'site';
import Module from 'utilities/module';
//import {Color} from 'utilities/color';
import RichContent from './rich_content';
@ -23,10 +22,13 @@ export default class ChatLine extends Module {
this.inject('settings');
this.inject('i18n');
this.inject('chat');
this.inject('site');
this.inject('site.fine');
this.inject('site.web_munch');
this.inject(RichContent);
this.inject('chat.actions');
this.ChatLine = this.fine.define(
'chat-line',
n => n.renderMessageBody && ! n.getMessageParts,
@ -49,6 +51,7 @@ export default class ChatLine extends Module {
this.chat.context.on('changed:chat.rituals.show', this.updateLines, this);
this.chat.context.on('changed:chat.rich.enabled', this.updateLines, this);
this.chat.context.on('changed:chat.rich.hide-tokens', this.updateLines, this);
this.chat.context.on('changed:chat.actions.inline', this.updateLines, this);
const t = this,
React = this.web_munch.getModule('react');
@ -77,7 +80,7 @@ export default class ChatLine extends Module {
}
cls.prototype.render = function() {
const types = t.parent.chat_types || {},
const types = t.parent.message_types || {},
msg = this.props.message,
is_action = msg.messageType === types.Action;
@ -98,21 +101,23 @@ export default class ChatLine extends Module {
const user = msg.user,
color = t.parent.colors.process(user.color),
/*bg_rgb = Color.RGBA.fromHex(user.color),
bg_color = bg_rgb.luminance() < .005 ? bg_rgb : bg_rgb.toHSLA().targetLuminance(0.005).toRGBA(),
bg_css = bg_color.toCSS(),*/
room = msg.channel ? msg.channel.slice(1) : undefined,
bg_css = null, //Math.random() > .7 ? t.parent.inverse_colors.process(user.color) : null,
room = msg.roomLogin ? msg.roomLogin : msg.channel ? msg.channel.slice(1) : undefined,
show = this._ffz_show = this.state.alwaysShowMessage || ! this.props.message.deleted;
if ( ! msg.message && msg.messageParts )
detokenizeMessage(msg);
const u = {
login: this.props.currentUserLogin,
display: this.props.currentUserDisplayName
},
tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, u),
const u = t.site.getUser(),
r = {id: this.props.channelID, login: room};
if ( u ) {
u.moderator = this.props.isCurrentUserModerator;
u.staff = this.props.isCurrentUserStaff;
}
const tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, u, r),
rich_content = FFZRichContent && t.chat.pluckRichContent(tokens, msg);
let cls = 'chat-line__message',
@ -120,7 +125,7 @@ export default class ChatLine extends Module {
this.props.showTimestamps && e('span', {
className: 'chat-line__timestamp'
}, t.chat.formatTime(msg.timestamp)),
this.renderModerationIcons(),
t.actions.renderInline(msg, this.props.showModerationIcons, u, r, e),
e('span', {
className: 'chat-line__message--badges'
}, t.chat.badges.render(msg, e)),
@ -225,6 +230,7 @@ export default class ChatLine extends Module {
return e('div', {
className: `${cls}${msg.mentioned ? ' ffz-mentioned' : ''}`,
style: {backgroundColor: bg_css},
'data-room-id': this.props.channelID,
'data-room': room,
'data-user-id': user.userID,

View file

@ -44,6 +44,11 @@ export default class SettingsMenu extends Module {
<button onClick={this.ffzSettingsClick}>
{t.i18n.t('site.menu_button', 'FrankerFaceZ Control Center')}
</button>
{t.cant_window && <div class="tw-mg-t-05 tw-c-text-alt-2">
<span class="ffz-i-attention">
{t.i18n.t('popup.error', 'We tried opening a pop-up window and could not. Make sure to allow pop-ups from Twitch.')}
</span>
</div>}
</div>);
return val;
@ -73,7 +78,14 @@ export default class SettingsMenu extends Module {
'_blank',
'resizable=yes,scrollbars=yes,width=850,height=600'
);
win.focus();
if ( win )
win.focus();
else {
this.cant_window = true;
this.SettingsMenu.forceUpdate();
return;
}
} else {
this.emit('site.menu_button:clicked');
}

View file

@ -53,6 +53,17 @@ export default class CSSTweaks extends Module {
// Layout
/*this.settings.add('layout.portrait', {
default: false,
ui: {
path: 'Appearance > Layout >> Channel',
title: 'Enable Portrait Mode',
description: 'In Portrait Mode, chat will be displayed beneath the player when the window is taller than it is wide.',
component: 'setting-check-box'
},
changed: val => this.toggle('portrait', val)
});*/
this.settings.add('layout.side-nav.show', {
default: true,
ui: {
@ -194,6 +205,7 @@ export default class CSSTweaks extends Module {
this.toggle('swap-sidebars', this.settings.get('layout.swap-sidebars'));
this.toggle('minimal-navigation', this.settings.get('layout.minimal-navigation'));
this.toggle('theatre-nav', this.settings.get('layout.theatre-navigation'));
//this.toggle('portrait', this.settings.get('layout.portrait'));
this.toggleHide('side-nav', !this.settings.get('layout.side-nav.show'));
this.toggleHide('side-rec-friends', !this.settings.get('layout.side-nav.show-rec-friends'));

View file

@ -3,6 +3,6 @@
padding-top: 5px;
}
.message > .chat-line__message--emoji {
.message > .chat-line__message--emote.ffz-emoji {
padding-top: 0px;
}

View file

@ -1,6 +1,6 @@
.tw-button-icon[data-a-target="emote-picker-button"] {
.tw-button-icon__icon {
padding: .4rem .2rem;
padding: .5rem .2rem .3rem;
figure {
&:before {

View file

@ -5,7 +5,7 @@
// ============================================================================
import {SiteModule} from 'utilities/module';
import {createElement, setChildren} from 'utilities/dom';
import {createElement} from 'utilities/dom';
export default class MenuButton extends SiteModule {
constructor(...args) {

View file

@ -57,6 +57,55 @@
}
.ffz-mod-icon {
text-align: center;
display: inline-flex;
& + .ffz-mod-icon {
margin-left: 1px;
}
span, img, figure {
pointer-events: none;
}
span, img,
figure:before {
margin: 0 0.05rem !important
}
span {
display: inline-block;
min-width: 1.6rem;
font-weight: bold;
}
.mod-icon__image img {
max-height: 1.6rem;
}
&.colored,
.mod-icon__image img {
opacity: 0.75;
}
.ffz--action &,
.tw-interactable:hover &,
&:hover {
.tw-theme--dark &, & {
&.tw-c-text-alt-2 {
color: inherit !important;
}
}
&.colored,
.mod-icon__image img {
opacity: 1;
}
}
}
.ffz--emote-picker {
section:not(.filtered) heading {
cursor: pointer;

View file

@ -190,6 +190,11 @@ export default class SocketClient extends Module {
}
ws.onopen = () => {
if ( this._socket !== ws ) {
this.log.warn('A socket connected that is not our primary socket.');
return ws.close();
}
this._state = State.CONNECTED;
this._sent_user = false;
@ -249,11 +254,17 @@ export default class SocketClient extends Module {
}
ws.onerror = () => {
if ( ws !== this._socket )
return;
if ( ! this._offline_time )
this._offline_time = Date.now();
}
ws.onclose = event => {
if ( ws !== this._socket )
return;
const old_state = this._state;
this.log.info(`Disconnected. (${event.code}:${event.reason})`);
@ -290,6 +301,9 @@ export default class SocketClient extends Module {
ws.onmessage = event => {
if ( ws !== this._socket )
return;
// Format:
// -1 <cmd_name>[ <json_data>]
// <reply-id> <ok/err>[ <json_data>]

View file

@ -0,0 +1,38 @@
<template lang="html">
<div
:class="classes"
class="tw-balloon tw-block tw-absolute"
>
<div class="tw-balloon__tail tw-overflow-hidden tw-absolute">
<div
:class="`tw-c-${color}`"
class="tw-balloon__tail-symbol tw-border-t tw-border-r tw-border-b tw-border-l tw-border-radius-small tw-absolute"
/>
</div>
<div class="tw-border-t tw-border-r tw-border-b tw-border-l tw-elevation-1 tw-border-radius-small">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
color: {
type: String,
default: 'background'
},
size: String,
dir: String
},
computed: {
classes() {
return `tw-c-${this.color} ${this.size ? `tw-balloon--${this.size}` : ''} ${this.dir ? this.dir.split('-').map(x => `tw-balloon--${x}`).join(' ') : ''}`;
}
}
}
</script>

View file

@ -0,0 +1,33 @@
<template lang="html">
<div
:class="classes"
:data-simplebar-auto-hide="autoHide"
:data-simplebar-scrollbar-min-size="scrollbarMinSize"
data-simplebar
class="scrollable-area"
>
<div class="simplebar-scroll-content">
<div class="simplebar-content">
<slot />
</div>
</div>
</div>
</template>
<script>
export default {
props: {
classes: String,
autoHide: {
type: Boolean,
default: true
},
scrollbarMinSize: {
type: Number,
default: 10
}
}
}
</script>

View file

@ -15,6 +15,7 @@ const BAD_ERRORS = [
'error internal',
'context deadline exceeded',
'unexpected service response',
'service unavailable',
'404',
'500',
'501',

View file

@ -123,13 +123,21 @@ export default class WebMunch extends Module {
// Grabbing Require
// ========================================================================
getRequire() {
getRequire(limit = 0) {
if ( this._require )
return Promise.resolve(this._require);
return new Promise(resolve => {
const fn = this._original_loader || window.webpackJsonp;
if ( ! fn ) {
if ( limit > 100 )
throw new Error('unable to find webpackJsonp');
return setTimeout(() => this.getRequire(limit++).then(resolve), 100);
}
const id = `${this._id}$${this._rid++}`;
(this._original_loader || window.webpackJsonp)(
fn(
[],
{
[id]: (module, exports, __webpack_require__) => {

View file

@ -91,14 +91,25 @@ export function createElement(tag, props, ...children) {
return el;
}
export function setChildren(el, children, no_sanitize) {
export function setChildren(el, children, no_sanitize, no_empty) {
if ( typeof children === 'string' ) {
if ( no_sanitize )
el.innerHTML = children;
else
el.textContent = children;
if ( no_empty ) {
el.appendChild(no_sanitize ?
range.createContextualFragment(children) :
document.createTextNode(children)
)
} else {
if ( no_sanitize )
el.innerHTML = children;
else
el.textContent = children;
}
} else if ( Array.isArray(children) ) {
if ( ! no_empty )
el.innerHTML = '';
for(const child of children)
if ( typeof child === 'string' )
el.appendChild(no_sanitize ?
@ -106,11 +117,18 @@ export function setChildren(el, children, no_sanitize) {
document.createTextNode(child)
);
else if ( Array.isArray(child) )
setChildren(el, child, no_sanitize, true);
else if ( child )
el.appendChild(child);
} else if ( children )
} else if ( children ) {
if ( ! no_empty )
el.innerHTML = '';
el.appendChild(children);
}
}

View file

@ -0,0 +1,236 @@
'use strict';
import {createElement} from 'utilities/dom';
// ============================================================================
// Font Awesome Data
// ============================================================================
export const ALIASES = {
'ban': ['ban', 'block'],
'ok': ['ok', 'unban', 'untimeout'],
'clock-o': ['clock-o', 'clock', 'timeout'],
'times': ['remove','close','times'],
'cog': ['gear','cog'],
'repeat': ['rotate-right','repeat'],
'outdent': ['dedent','outdent'],
'picture-o': ['photo','image','picture-o'],
'pencil-square-o': ['edit','pencil-square-o'],
'share': ['mail-forward','share'],
'exclamation-triangle': ['warning','exclamation-triangle'],
'bar-chart': ['bar-chart-o','bar-chart'],
'cogs': ['gears','cogs'],
'facebook': ['facebook-f','facebook'],
'rss': ['feed','rss'],
'users': ['group','users'],
'link': ['chain','link'],
'scissors': ['cut','scissors'],
'files-o': ['copy','files-o'],
'floppy-o': ['save','floppy-o'],
'bars': ['navicon','reorder','bars'],
'sort': ['unsorted','sort'],
'sort-desc': ['sort-down','sort-desc'],
'sort-asc': ['sort-up','sort-asc'],
'undo': ['rotate-left','undo'],
'gavel': ['legal','gavel'],
'tachometer': ['dashboard','tachometer'],
'bolt': ['flash','bolt'],
'clipboard': ['paste','clipboard'],
'mobile': ['mobile-phone','mobile'],
'reply': ['mail-reply','reply'],
'reply-all': ['mail-reply-all','reply-all'],
'star-half-o': ['star-half-empty','star-half-full','star-half-o'],
'chain-broken': ['unlink','chain-broken'],
'caret-square-o-down': ['toggle-down','caret-square-o-down'],
'caret-square-o-up': ['toggle-up','caret-square-o-up'],
'caret-square-o-right': ['toggle-right','caret-square-o-right'],
'eur': ['euro','eur'],
'usd': ['dollar','usd'],
'inr': ['rupee','inr'],
'jpy': ['cny','rmb','yen','jpy'],
'rub': ['ruble','rouble','rub'],
'krw': ['won','krw'],
'btc': ['bitcoin','btc'],
'gratipay': ['gittip','gratipay'],
'caret-square-o-left': ['toggle-left','caret-square-o-left'],
'try': ['turkish-lira','try'],
'university': ['institution','bank','university'],
'graduation-cap': ['mortar-board','graduation-cap'],
'car': ['automobile','car'],
'taxi': ['cab','taxi'],
'file-image-o': ['file-photo-o','file-picture-o','file-image-o'],
'file-archive-o': ['file-zip-o','file-archive-o'],
'file-audio-o': ['file-sound-o','file-audio-o'],
'file-video-o': ['file-movie-o','file-video-o'],
'life-ring': ['life-bouy','life-buoy','life-saver','support','life-ring'],
'rebel': ['ra','resistance','rebel'],
'empire': ['ge','empire'],
'hacker-news': ['y-combinator-square','yc-square','hacker-news'],
'weixin': ['wechat','weixin'],
'paper-plane': ['send','paper-plane'],
'paper-plane-o': ['send-o','paper-plane-o'],
'futbol-o': ['soccer-ball-o','futbol-o'],
'ils': ['shekel','sheqel','ils'],
'transgender': ['intersex','transgender'],
'bed': ['hotel','bed'],
'y-combinator': ['yc','y-combinator'],
'battery-full': ['battery-4','battery','battery-full'],
'battery-three-quarters': ['battery-3','battery-three-quarters'],
'battery-half': ['battery-2','battery-half'],
'battery-quarter': ['battery-1','battery-quarter'],
'battery-empty': ['battery-0','battery-empty'],
'hourglass-start': ['hourglass-1','hourglass-start'],
'hourglass-half': ['hourglass-2','hourglass-half'],
'hourglass-end': ['hourglass-3','hourglass-end'],
'hand-rock-o': ['hand-grab-o','hand-rock-o'],
'hand-paper-o': ['hand-stop-o','hand-paper-o'],
'television': ['tv','television'],
'american-sign-language-interpreting': ['asl-interpreting','american-sign-language-interpreting'],
'deaf': ['deafness','hard-of-hearing','deaf'],
'sign-language': ['signing','sign-language'],
'google-plus-official': ['google-plus-circle','google-plus-official'],
'font-awesome': ['fa','font-awesome'],
'address-card': ['vcard','address-card'],
'address-card-o': ['vcard-o','address-card-o'],
'id-card': ['drivers-license','id-card'],
'id-card-o': ['drivers-license-o','id-card-o'],
'thermometer-full': ['thermometer-4','thermometer','thermometer-full'],
'thermometer-three-quarters': ['thermometer-3','thermometer-three-quarters'],
'thermometer-half': ['thermometer-2','thermometer-half'],
'thermometer-quarter': ['thermometer-1','thermometer-quarter'],
'thermometer-empty': ['thermometer-0','thermometer-empty'],
'bath': ['bathtub','s15','bath'],
'window-close': ['times-rectangle','window-close'],
'window-close-o': ['times-rectangle-o','window-close-o']
};
export const ICONS = [
'glass','music','search','envelope-o','heart','star','star-o','user',
'film','th-large','th','th-list','check','times','search-plus',
'search-minus','power-off','signal','cog','trash-o','home','file-o',
'clock-o','road','download','arrow-circle-o-down','arrow-circle-o-up',
'inbox','play-circle-o','repeat','refresh','list-alt','lock','flag',
'headphones','volume-off','volume-down','volume-up','qrcode','barcode',
'tag','tags','book','bookmark','print','camera','font','bold','italic',
'text-height','text-width','align-left','align-center','align-right',
'align-justify','list','outdent','indent','video-camera','picture-o',
'pencil','map-marker','adjust','tint','pencil-square-o','share-square-o',
'check-square-o','arrows','step-backward','fast-backward','backward',
'play','pause','stop','forward','fast-forward','step-forward','eject',
'chevron-left','chevron-right','plus-circle','minus-circle','times-circle',
'check-circle','question-circle','info-circle','crosshairs',
'times-circle-o','check-circle-o','ban','arrow-left','arrow-right',
'arrow-up','arrow-down','share','expand','compress','plus','minus',
'asterisk','exclamation-circle','gift','leaf','fire','eye','eye-slash',
'exclamation-triangle','plane','calendar','random','comment','magnet',
'chevron-up','chevron-down','retweet','shopping-cart','folder','folder-open',
'arrows-v','arrows-h','bar-chart','twitter-square','facebook-square',
'camera-retro','key','cogs','comments','thumbs-o-up','thumbs-o-down',
'star-half','heart-o','sign-out','linkedin-square','thumb-tack','external-link',
'sign-in','trophy','github-square','upload','lemon-o','phone','square-o',
'bookmark-o','phone-square','twitter','facebook','github','unlock',
'credit-card','rss','hdd-o','bullhorn','bell','certificate','hand-o-right',
'hand-o-left','hand-o-up','hand-o-down','arrow-circle-left','arrow-circle-right',
'arrow-circle-up','arrow-circle-down','globe','wrench','tasks','filter',
'briefcase','arrows-alt','users','link','cloud','flask','scissors','files-o',
'paperclip','floppy-o','square','bars','list-ul','list-ol','strikethrough',
'underline','table','magic','truck','pinterest','pinterest-square',
'google-plus-square','google-plus','money','caret-down','caret-up',
'caret-left','caret-right','columns','sort','sort-desc','sort-asc',
'envelope','linkedin','undo','gavel','tachometer','comment-o','comments-o',
'bolt','sitemap','umbrella','clipboard','lightbulb-o','exchange',
'cloud-download','cloud-upload','user-md','stethoscope','suitcase','bell-o',
'coffee','cutlery','file-text-o','building-o','hospital-o','ambulance',
'medkit','fighter-jet','beer','h-square','plus-square','angle-double-left',
'angle-double-right','angle-double-up','angle-double-down','angle-left',
'angle-right','angle-up','angle-down','desktop','laptop','tablet','mobile',
'circle-o','quote-left','quote-right','spinner','circle','reply','github-alt',
'folder-o','folder-open-o','smile-o','frown-o','meh-o','gamepad','keyboard-o',
'flag-o','flag-checkered','terminal','code','reply-all','star-half-o',
'location-arrow','crop','code-fork','chain-broken','question','info',
'exclamation','superscript','subscript','eraser','puzzle-piece','microphone',
'microphone-slash','shield','calendar-o','fire-extinguisher','rocket',
'maxcdn','chevron-circle-left','chevron-circle-right','chevron-circle-up',
'chevron-circle-down','html5','css3','anchor','unlock-alt','bullseye',
'ellipsis-h','ellipsis-v','rss-square','play-circle','ticket','minus-square',
'minus-square-o','level-up','level-down','check-square','pencil-square',
'external-link-square','share-square','compass','caret-square-o-down',
'caret-square-o-up','caret-square-o-right','eur','gbp','usd','inr','jpy','rub',
'krw','btc','file','file-text','sort-alpha-asc','sort-alpha-desc',
'sort-amount-asc','sort-amount-desc','sort-numeric-asc','sort-numeric-desc',
'thumbs-up','thumbs-down','youtube-square','youtube','xing','xing-square',
'youtube-play','dropbox','stack-overflow','instagram','flickr','adn','bitbucket',
'bitbucket-square','tumblr','tumblr-square','long-arrow-down','long-arrow-up',
'long-arrow-left','long-arrow-right','apple','windows','android','linux',
'dribbble','skype','foursquare','trello','female','male','gratipay','sun-o',
'moon-o','archive','bug','vk','weibo','renren','pagelines','stack-exchange',
'arrow-circle-o-right','arrow-circle-o-left','caret-square-o-left','dot-circle-o',
'wheelchair','vimeo-square','try','plus-square-o','space-shuttle','slack',
'envelope-square','wordpress','openid','university','graduation-cap','yahoo',
'google','reddit','reddit-square','stumbleupon-circle','stumbleupon','delicious',
'digg','pied-piper-pp','pied-piper-alt','drupal','joomla','language','fax',
'building','child','paw','spoon','cube','cubes','behance','behance-square',
'steam','steam-square','recycle','car','taxi','tree','spotify','deviantart',
'soundcloud','database','file-pdf-o','file-word-o','file-excel-o',
'file-powerpoint-o','file-image-o','file-archive-o','file-audio-o','file-video-o',
'file-code-o','vine','codepen','jsfiddle','life-ring','circle-o-notch','rebel',
'empire','git-square','git','hacker-news','tencent-weibo','qq','weixin',
'paper-plane','paper-plane-o','history','circle-thin','header','paragraph',
'sliders','share-alt','share-alt-square','bomb','futbol-o','tty','binoculars',
'plug','slideshare','twitch','yelp','newspaper-o','wifi','calculator','paypal',
'google-wallet','cc-visa','cc-mastercard','cc-discover','cc-amex','cc-paypal',
'cc-stripe','bell-slash','bell-slash-o','trash','copyright','at','eyedropper',
'paint-brush','birthday-cake','area-chart','pie-chart','line-chart','lastfm',
'lastfm-square','toggle-off','toggle-on','bicycle','bus','ioxhost','angellist',
'cc','ils','meanpath','buysellads','connectdevelop','dashcube','forumbee',
'leanpub','sellsy','shirtsinbulk','simplybuilt','skyatlas','cart-plus',
'cart-arrow-down','diamond','ship','user-secret','motorcycle','street-view',
'heartbeat','venus','mars','mercury','transgender','transgender-alt','venus-double',
'mars-double','venus-mars','mars-stroke','mars-stroke-v','mars-stroke-h','neuter',
'genderless','facebook-official','pinterest-p','whatsapp','server','user-plus',
'user-times','bed','viacoin','train','subway','medium','y-combinator','optin-monster',
'opencart','expeditedssl','battery-full','battery-three-quarters','battery-half',
'battery-quarter','battery-empty','mouse-pointer','i-cursor','object-group',
'object-ungroup','sticky-note','sticky-note-o','cc-jcb','cc-diners-club','clone',
'balance-scale','hourglass-o','hourglass-start','hourglass-half','hourglass-end',
'hourglass','hand-rock-o','hand-paper-o','hand-scissors-o','hand-lizard-o',
'hand-spock-o','hand-pointer-o','hand-peace-o','trademark','registered',
'creative-commons','gg','gg-circle','tripadvisor','odnoklassniki',
'odnoklassniki-square','get-pocket','wikipedia-w','safari','chrome','firefox',
'opera','internet-explorer','television','contao','500px','amazon',
'calendar-plus-o','calendar-minus-o','calendar-times-o','calendar-check-o',
'industry','map-pin','map-signs','map-o','map','commenting','commenting-o',
'houzz','vimeo','black-tie','fonticons','reddit-alien','edge','credit-card-alt',
'codiepie','modx','fort-awesome','usb','product-hunt','mixcloud','scribd',
'pause-circle','pause-circle-o','stop-circle','stop-circle-o','shopping-bag',
'shopping-basket','hashtag','bluetooth','bluetooth-b','percent','gitlab',
'wpbeginner','wpforms','envira','universal-access','wheelchair-alt',
'question-circle-o','blind','audio-description','volume-control-phone','braille',
'assistive-listening-systems','american-sign-language-interpreting','deaf',
'glide','glide-g','sign-language','low-vision','viadeo','viadeo-square',
'snapchat','snapchat-ghost','snapchat-square','pied-piper','first-order',
'yoast','themeisle','google-plus-official','font-awesome','handshake-o',
'envelope-open','envelope-open-o','linode','address-book','address-book-o',
'address-card','address-card-o','user-circle','user-circle-o','user-o',
'id-badge','id-card','id-card-o','quora','free-code-camp','telegram',
'thermometer-full','thermometer-three-quarters','thermometer-half',
'thermometer-quarter','thermometer-empty','shower','bath','podcast',
'window-maximize','window-minimize','window-restore','window-close',
'window-close-o','bandcamp','grav','etsy','imdb','ravelry','eercast',
'microchip','snowflake-o','superpowers','wpexplorer','meetup'];
let loaded = false;
import FA_URL from 'styles/font-awesome.scss';
export const load = () => {
if ( loaded )
return;
document.head.appendChild(createElement('link', {
href: FA_URL,
rel: 'stylesheet',
type: 'text/css',
crossOrigin: 'anonymouse'
}));
}

View file

@ -246,6 +246,11 @@ export function pick_random(obj) {
}
export const escape_regex = RegExp.escape || function escape_regex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export class SourcedSet {
constructor() {
this._cache = [];

View file

@ -29,7 +29,7 @@
}
.ffz-i-lock {
.ffz__tooltip .ffz-i-lock {
position: absolute;
bottom: 0; right: 0;
border-radius: .2rem;

2187
styles/font-awesome.scss vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -101,6 +101,8 @@
.ffz-i-block:before { content: '\e824'; } /* '' */
.ffz-i-pin:before { content: '\e825'; } /* '' */
.ffz-i-pin-outline:before { content: '\e826'; } /* '' */
.ffz-i-gift:before { content: '\e827'; } /* '' */
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */
.ffz-i-gauge:before { content: '\f0e4'; } /* '' */
.ffz-i-download-cloud:before { content: '\f0ed'; } /* '' */
@ -111,6 +113,7 @@
.ffz-i-twitch:before { content: '\f1e8'; } /* '' */
.ffz-i-bell-off:before { content: '\f1f7'; } /* '' */
.ffz-i-trash:before { content: '\f1f8'; } /* '' */
.ffz-i-eyedropper:before { content: '\f1fb'; } /* '' */
.ffz-i-window-maximize:before { content: '\f2d0'; } /* '' */
.ffz-i-window-minimize:before { content: '\f2d1'; } /* '' */
.ffz-i-window-restore:before { content: '\f2d2'; } /* '' */

View file

@ -5,60 +5,53 @@ body {
.ffz-metadata-balloon {
z-index: 999999999 !important;
margin: 6px;
}
.tw-balloon {
&[x-placement^="bottom"] > .tw-balloon__tail {
top: -3px;
box-shadow: -1px -1px 0 #dad8de;
bottom: 100%;
.tw-theme--dark & {
box-shadow: -1px -1px 0 #2c2541;
}
.tw-theme--ffz & {
box-shadow: -1px -1px 0 var(--ffz-color-20);
.tw-balloon__tail-symbol {
top: auto;
bottom: -8px;
left: 8px;
}
}
&[x-placement^="top"] > .tw-balloon__tail {
bottom: -3px;
box-shadow: 1px 1px 0 #dad8de;
top: 100%;
.tw-theme--dark & {
box-shadow: 1px 1px 0 #2c2541;
}
.tw-theme--ffz & {
box-shadow: 1px 1px 0 var(--ffz-color-20);
.tw-balloon__tail-symbol {
top: auto;
bottom: 8px;
left: 8px;
}
}
&[x-placement^="right"] > .tw-balloon__tail {
left: -3px;
box-shadow: -1px 1px 0 #dad8de;
right: 100%;
.tw-theme--dark & {
box-shadow: -1px 1px 0 #2c2541;
}
.tw-theme--ffz & {
box-shadow: -1px 1px 0 var(--ffz-color-20);
.tw-balloon__tail-symbol {
left: auto;
right: -8px;
top: 8px;
}
}
&[x-placement^="left"] > .tw-balloon__tail {
right: -3px;
box-shadow: 1px -1px 0 #dad8de;
left: 100%;
.tw-theme--dark & {
box-shadow: 1px -1px 0 #2c2541;
}
.tw-theme--ffz & {
box-shadow: 1px -1px 0 var(--ffz-color-20);
.tw-balloon__tail-symbol {
left: auto;
right: 8px;
top: 8px;
}
}
}
.ffz-metadata-balloon,
.ffz__tooltip {
.loader {

View file

@ -12,7 +12,6 @@
.tw-width-auto { width: auto !important }
.ffz--widget {
input, select {
min-width: 20rem;
}
@ -23,6 +22,34 @@
}
.ffz--color-widget input,
.ffz--inline label {
min-width: unset;
}
.ffz-color-preview {
margin: 1px;
figure.ffz-i-eyedropper {
opacity: 0.5;
}
&:hover, &:focus {
figure {
opacity: 1
}
}
figure {
width: 3rem;
margin: .4rem;
height: calc(100% - .8rem);
}
}
.ffz--menu-page {
padding: 1rem;
@ -67,7 +94,9 @@
.ffz--profile__icon {
font-size: 2rem;
}
}
.ffz--profile {
.ffz-i-ok { color: green }
}