1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
* Added: Initial re-implementation of emote information cards. These have been broken for a while due to changes in Twitch's website preventing us from accessing them.
* Added: Setting to hide charity progress elements in chat.
* Changed: Emote tool-tips now display emote artists.
* Changed: The `Chat Command` chat action can now optionally be sent in another channel. Note that when doing so, you will not receive feedback from your sent message.
* Fixed: The 'Viewer Count' tool-tip duplicating itself.
* Fixed: Emote menu repeatedly requesting FFZ data.

* API Added: Ephemeral profiles can now be created by passing `ephemeral: true` in the options when creating a profile. These profiles are temporary and read-only.
This commit is contained in:
SirStendec 2023-03-27 18:50:32 -04:00
parent daa193aa03
commit 21bc0a704f
54 changed files with 1955 additions and 161 deletions

View file

@ -881,6 +881,20 @@
"search": [
"fx"
]
},
{
"uid": "3c736f432d2e3ec0d6b5d2f193b93345",
"css": "artist",
"code": 59470,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M200 500V535C200 685 320 800 465 800H535A95 95 0 0 0 630 705V690A150 150 0 0 1 680 585L770 500C790 480 800 455 800 430V400L880 320C895 355 900 390 900 430A200 200 0 0 1 835 575L750 660A50 50 0 0 0 735 690V705A195 195 0 0 1 535 900H470C270 900 105 735 105 535V500A400 400 0 0 1 505 100H575C675 100 760 140 825 210L805 225 705 325 605 405V450A150 150 0 0 1 455 600H300L335 565C350 550 360 525 360 500A150 150 0 0 1 510 350H545L625 250 650 225 660 215C630 205 605 200 570 200H500A300 300 0 0 0 200 500Z",
"width": 1000
},
"search": [
"artist"
]
}
]
}

View file

@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.43.0",
"version": "4.44.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true,
"license": "Apache-2.0",

Binary file not shown.

View file

@ -162,6 +162,8 @@
<glyph glyph-name="fx" unicode="&#xe84d;" d="M435 820c-23-4-53-16-78-33-17-11-43-38-56-56-17-26-33-64-43-103l-5-18-40 0-41-1-8-33c-5-18-9-34-9-35 0-1 9-1 40-1 26 0 39-1 39-2 0-3-64-261-69-274-6-21-13-34-19-38-11-6-27-7-66-3-31 2-41 2-53-4-33-14-36-58-6-79 12-8 23-11 41-12 50-2 98 19 141 62 21 21 34 39 48 66 21 42 22 45 71 244l9 40 65-1c36-1 73-1 82-2l18-1 8-36c5-20 9-37 9-38 0-2-28-44-41-62-11-14-34-39-36-39-1 0-4 2-7 5-18 16-40 16-57 1-10-9-15-20-15-34 0-10 1-13 5-21 7-15 20-24 39-28 14-3 28-1 44 5 23 9 48 31 75 68 8 10 15 18 15 17 1 0 3-5 5-11 10-28 34-66 52-84 19-20 44-30 75-30 17 0 29 4 43 13 22 15 34 28 38 44 5 18 1 31-11 43-9 9-16 12-28 12-6 0-11-1-17-5-11-5-16-10-23-26-3-6-6-12-6-12-6 0-19 16-28 33-8 16-9 19-21 76l-10 47 5 8c17 26 40 51 52 56 5 2 14 4 26 5 10 1 22 3 27 5 19 5 33 21 34 40 0 33-29 54-65 45-27-8-66-35-90-65-3-4-7-8-7-8 0 0-3 6-5 13-7 23-18 36-33 40-5 1-33 0-95-1-49-1-92-2-96-2l-8 1 6 26c12 50 23 77 37 91 7 7 8 8 14 8 4 0 16-2 27-4 49-9 62-7 78 9 17 18 16 47-3 65-16 14-41 19-73 14z" horiz-adv-x="1000" />
<glyph glyph-name="artist" unicode="&#xe84e;" d="M200 350v-35c0-150 120-265 265-265h70a95 95 0 0 1 95 95v15a150 150 0 0 0 50 105l90 85c20 20 30 45 30 70v30l80 80c15-35 20-70 20-110a200 200 0 0 0-65-145l-85-85a50 50 0 0 1-15-30v-15a195 195 0 0 0-200-195h-65c-200 0-365 165-365 365v35a400 400 0 0 0 400 400h70c100 0 185-40 250-110l-20-15-100-100-100-80v-45a150 150 0 0 0-150-150h-155l35 35c15 15 25 40 25 65a150 150 0 0 0 150 150h35l80 100 25 25 10 10c-30 10-55 15-90 15h-70a300 300 0 0 1-300-300z" horiz-adv-x="1000" />
<glyph glyph-name="move" unicode="&#xf047;" d="M1000 350q0-14-11-25l-142-143q-11-11-26-11t-25 11-10 25v72h-215v-215h72q14 0 25-10t11-25-11-25l-143-143q-10-11-25-11t-25 11l-143 143q-11 10-11 25t11 25 25 10h72v215h-215v-72q0-14-10-25t-25-11-25 11l-143 143q-11 11-11 25t11 25l143 143q10 11 25 11t25-11 10-25v-72h215v215h-72q-14 0-25 10t-11 25 11 26l143 142q11 11 25 11t25-11l143-142q11-11 11-26t-11-25-25-10h-72v-215h215v72q0 14 10 25t25 11 26-11l142-143q11-10 11-25z" horiz-adv-x="1000" />
<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" />

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -3,8 +3,8 @@
"name": "Modular Chat Line Rendering",
"description": "Enable a newer, modular chat line renderer.",
"groups": [
{"value": true, "weight": 20},
{"value": false, "weight": 80}
{"value": true, "weight": 80},
{"value": false, "weight": 20}
]
},
"api_load": {

View file

@ -1,42 +1,68 @@
<template lang="html">
<div class="tw-flex tw-align-items-start">
<label :for="'edit_chat$' + id" class="tw-mg-y-05">
{{ t('setting.actions.chat', 'Chat Command') }}
</label>
<div>
<div class="tw-flex tw-align-items-start">
<label :for="'edit_chat$' + id" class="tw-mg-y-05">
{{ t('setting.actions.chat', 'Chat Command') }}
</label>
<div class="tw-full-width">
<input
:id="'edit_chat$' + id"
v-model="value.command"
:placeholder="defaults.command"
class="tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
@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 class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.formats', 'Available Formatters: {fmts}', {fmts}) }}
</div>
<div class="ffz-checkbox">
<div class="tw-full-width">
<input
:id="'chat-paste$' + id"
v-model="value.paste"
type="checkbox"
class="ffz-checkbox__input"
@change="$emit('input', value)"
:id="'edit_chat$' + id"
v-model="value.command"
:placeholder="defaults.command"
class="tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
@input="$emit('input', value)"
>
<label :for="'chat-paste$' + id" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('setting.actions.set-chat', 'Paste this message into chat rather than sending it directly.') }}
</span>
</label>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
</div>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.formats', 'Available Formatters: {fmts}', {fmts}) }}
</div>
<div class="ffz-checkbox">
<input
:id="'chat-paste$' + id"
v-model="value.paste"
type="checkbox"
class="ffz-checkbox__input"
@change="$emit('input', value)"
>
<label :for="'chat-paste$' + id" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('setting.actions.set-chat', 'Paste this message into chat rather than sending it directly.') }}
</span>
</label>
</div>
</div>
</div>
<div class="tw-flex tw-align-items-start">
<label :for="'edit_chat_target$' + id" class="tw-mg-y-05">
{{ t('setting.actions.chat.target', 'Target Channel') }}
</label>
<div class="tw-full-width">
<input
:id="'edit_chat_target$' + id"
v-model="value.target"
:placeholder="defaults.target || ''"
class="tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
@input="$emit('input', value)"
>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.chat.target-notice', 'Please note that when sending a message into another chat, you will not receive feedback that your message was sent.') }}
</div>
<div class="tw-c-text-alt-2">
{{ t('setting.actions.chat.target-incompatible', 'Note: This is not compatible with pasting a message into chat, and will not function if that is enabled.') }}
</div>
</div>
</div>
</div>
</template>

View file

@ -238,7 +238,10 @@ export const open_url = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-url.vue'),
title: 'Open URL',
description: '{options.url}',
description(data) {
return data.options.url;
},
description_i18n: null,
can_self: true,
@ -287,7 +290,20 @@ export const chat = {
},
title: 'Chat Command',
description: '{options.command}',
description(data) {
if ( data.options.paste )
return this.t('chat.actions.chat.desc.paste', 'Paste into chat: {cmd}', {cmd: data.options.command})
const target = data.options.target ?? '';
return this.t('chat.actions.chat.desc.target', 'Send in {target}: {cmd}', {
cmd: data.options.command,
target: /^\s*$/.test(target)
? this.t('chat.actions.chat.desc.current', 'current channel')
: target
});
},
description_i18n: null,
can_self: true,
@ -295,10 +311,15 @@ export const chat = {
tooltip(data) {
const msg = this.replaceVariables(data.options.command, data);
let target = this.replaceVariables(data.options.target ?? '', data);
if ( /^\s*$/.test(target) )
target = null;
return [
(<div class="tw-border-b tw-mg-b-05">{ // eslint-disable-line react/jsx-key
this.i18n.t('chat.actions.chat', 'Chat Command')
target
? this.i18n.t('chat.actions.chat.with-target', 'Chat Command in Channel: {target}', {target})
: this.i18n.t('chat.actions.chat', 'Chat Command')
}</div>),
(<div class="tw-align-left">{ // eslint-disable-line react/jsx-key
msg
@ -308,10 +329,14 @@ export const chat = {
click(event, data) {
const msg = this.replaceVariables(data.options.command, data);
let target = this.replaceVariables(data.options.target ?? '', data);
if ( data.options.paste || /^\s*$/.test(target) )
target = data.room.login;
if ( data.options.paste )
this.pasteMessage(data.room.login, msg);
this.pasteMessage(target, msg);
else
this.sendMessage(data.room.login, msg);
this.sendMessage(target, msg);
}
}

View file

@ -1,6 +1,11 @@
query FFZ_GetEmoteInfo($id: ID!) {
emote(id: $id) {
id
artist {
id
login
displayName
}
owner {
id
login

View file

@ -23,10 +23,10 @@ const Flags = make_enum_flags(
'FlipX',
'FlipY',
'GrowX',
'GrowY',
'ShrinkX',
'ShrinkY',
'Rotate45',
'Slide',
'Appear',
'Leave',
'Rotate',
'Rotate90',
'Greyscale',
'Sepia',
@ -42,6 +42,70 @@ export const MODIFIER_FLAGS = Flags;
export const MODIFIER_KEYS = Object.values(MODIFIER_FLAGS).filter(x => typeof x === 'number');
const APPEAR_FRAMES = [
[0, -18, 0, 0],
[19.99, -18, 0, 0],
[20, -18, 0.1, 0],
[25, -16, 0.2, 0.6],
[30, -14, 0.3, -4],
[35, -12, 0.4, 0.6],
[40, -10, 0.5, -4],
[45, -8, 0.6, 2],
[50, -6, 0.7, -3],
[55, -4, 0.8, 2],
[60, -2, 0.9, -3],
[65, 0, 1, 0],
[100, 0, 1, 0]
];
const LEAVE_FRAMES = [
[0, 0, 1, 0],
[39.99, 1, 1, 0],
[40, 0, -.9, .9, -3],
[45, -2, -.8, .8, 2],
[50, -4, -.7, .7, -3],
[55, -6, -.6, .6, 2],
[60, -8, -.5, .5, -4],
[65, -10, -.4, .4, .6],
[70, -12, -.3, .3, -4],
[75, -14, -.2, .2, .6],
[80, 16, -.1, .1, 0],
[85, -18, -0.01, 0, 0],
[100, -18, -0.01, 0, 0]
];
function appearLeaveToKeyframes(source, multi = 1, offset = 0, has_var = false) {
const out = [];
for(const line of source) {
const pct = (line[0] * multi) + offset;
let vr, tx, scale, ty;
vr = has_var ? `var(--ffz-effect-transforms) ` : '';
tx = line[1] === 0 ? '' : `translateX(${line[1]}px) `;
if ( line.length === 4 ) {
scale = `scale(${line[2]})`;
ty = line[3] === 0 ? '' : ` translateY(${line[3]}px)`;
} else {
const sx = line[2],
sy = line[3];
scale = `scale(${sx}, ${sy})`;
ty = line[4] === 0 ? '' : ` translateY(${line[4]}px)`;
}
out.push(`\t${pct}% { transform:${vr}${tx}${scale}${ty}; }`);
}
return out.join('\n');
}
const EFFECT_STYLES = [
{
setting: 'FlipX',
@ -66,24 +130,87 @@ const EFFECT_STYLES = [
title: 'Stretch Horizontal'
},
{
setting: 'ShrinkY',
flags: Flags.ShrinkY,
title: 'Squish Vertical'
setting: 'Slide',
flags: Flags.Slide,
//not_flags: Flags.Rotate,
title: 'Slide Animation',
as_background: true,
animation: 'ffz-effect-slide var(--ffz-speed-x) linear infinite',
raw: `@keyframes ffz-effect-slide {
0% { background-position-x: 0; }
100% { background-position-x: calc(-1 * var(--ffz-width)); }
}`
},
{
setting: 'GrowY',
flags: Flags.GrowY,
title: 'Stretch Vertical'
setting: 'Appear',
flags: Flags.Appear,
not_flags: Flags.Leave,
title: 'Appear Animation',
animation: 'ffz-effect-appear 3s infinite linear',
animationTransform: 'ffz-effect-appear-transform 3s linear infinite',
raw: `@keyframes ffz-effect-appear {
${appearLeaveToKeyframes(APPEAR_FRAMES)}
}
@keyframes ffz-effect-appear-transform {
${appearLeaveToKeyframes(APPEAR_FRAMES, 1, 0, true)}
}`
},
{
setting: 'Leave',
flags: Flags.Leave,
not_flags: Flags.Appear,
title: 'Leave Animation',
animation: 'ffz-effect-leave 3s infinite linear',
animationTransform: 'ffz-effect-leave-transform 3s infinite linear',
raw: `@keyframes ffz-effect-leave {
${appearLeaveToKeyframes(LEAVE_FRAMES)}
}
@keyframes ffz-effect-leave-transform {
${appearLeaveToKeyframes(LEAVE_FRAMES, 1, 0, true)}
}`
},
{
setting: [
'Appear',
'Leave'
],
flags: Flags.Appear | Flags.Leave,
animation: 'ffz-effect-in-out 6s infinite linear',
animationTransform: 'ffz-effect-in-out-transform 6s linear infinite',
raw: `@keyframes ffz-effect-in-out {
${appearLeaveToKeyframes(APPEAR_FRAMES, 0.5, 0)}
${appearLeaveToKeyframes(LEAVE_FRAMES, 0.5, 50)}
}
@keyframes ffz-effect-in-out-transform {
${appearLeaveToKeyframes(APPEAR_FRAMES, 0.5, 0, true)}
${appearLeaveToKeyframes(LEAVE_FRAMES, 0.5, 50, true)}
}`
},
{
setting: 'Rotate',
flags: Flags.Rotate,
not_flags: Flags.Slide,
title: 'Rotate Animation',
no_wide: true,
animation: 'ffz-effect-rotate 1.5s infinite linear',
animationTransform: 'ffz-effect-rotate-transform 1.5s infinite linear',
raw: `@keyframes ffz-effect-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes ffz-effect-rotate-transform {
0% { transform: var(--ffz-effect-transforms) rotate(0deg); }
100% { transform: var(--ffz-effect-transforms) rotate(360deg); }
}`
},
/*{
setting: 'Rotate45',
flags: MODIFIER_FLAGS.Rotate45,
title: 'Rotate 45 Degrees'
},
{
setting: 'Rotate90',
flags: MODIFIER_FLAGS.Rotate90,
title: 'Rotate 90 Degrees'
setting: [
'Slide',
'Rotate'
],
flags: Flags.Rotate | Flags.Slide,
// Sync up the speed for slide and rotate if both are applied.
animation: 'ffz-effect-slide calc(1.5 * var(--ffz-speed-x)) linear infinite'
},
{
setting: 'Greyscale',
@ -242,7 +369,7 @@ const EFFECT_STYLES = [
function generateBaseFilterCss() {
const out = [
`.modified-emote[data-effects] > img {
`.modified-emote[data-effects] > .chat-line__message--emote {
--ffz-effect-filters: none;
--ffz-effect-transforms: initial;
--ffz-effect-animations: initial;
@ -314,6 +441,7 @@ export default class Emotes extends Module {
this.twitch_inventory_sets = new Set; //(EXTRA_INVENTORY);
this.__twitch_emote_to_set = {};
this.__twitch_set_to_channel = {};
this.__twitch_emote_to_artist = {};
// Bulk data structure for collections applied to a lot of users.
// This lets us avoid allocating lots of individual user
@ -548,6 +676,9 @@ export default class Emotes extends Module {
if ( (flags & input.flags) !== input.flags )
continue;
if ( input.not_flags && (flags & input.not_flags) === input.not_flags )
continue;
if ( input.animation )
animations.push(input);
@ -584,7 +715,7 @@ export default class Emotes extends Module {
if ( ! filter && ! transform && ! animation )
return null;
return `.modified-emote[data-effects="${flags}"] > img {${filter ? `
return `.modified-emote[data-effects="${flags}"] > .chat-line__message--emote {${filter ? `
--ffz-effect-filters: ${filter};
filter: var(--ffz-effect-filters);` : ''}${transformOrigin ? `
transform-origin: ${transformOrigin};` : ''}${transform ? `
@ -602,6 +733,9 @@ export default class Emotes extends Module {
this.effects_enabled = {};
this.activeEffectStyles = [];
this.activeAsBackgroundMask = 0;
this.activeNoWideMask = 0;
for(const input of EFFECT_STYLES) {
if ( input.setting && ! Array.isArray(input.setting) )
this.effects_enabled[input.setting] = this.parent.context.get(`chat.effects.${input.setting}`);
@ -619,8 +753,14 @@ export default class Emotes extends Module {
} else if ( input.setting )
enabled = this.effects_enabled[input.setting];
if ( enabled )
if ( enabled ) {
this.activeEffectStyles.push(input);
if ( input.as_background )
this.activeAsBackgroundMask = this.activeAsBackgroundMask | input.flags;
if ( input.no_wide )
this.activeNoWideMask = this.activeNoWideMask | input.flags;
}
}
this.effect_style.clear();
@ -820,10 +960,14 @@ export default class Emotes extends Module {
this.settings.provider.set(key, favs);
}
handleClick(event) {
handleClick(event, favorite_only = false) {
const target = event.target,
ds = target && target.dataset;
/*const modified = target.closest('.modified-emote');
if ( modified && modified !== target )
return;*/
if ( ! ds )
return;
@ -928,9 +1072,14 @@ export default class Emotes extends Module {
return true;
}
if ( favorite_only )
return false;
const evt = new FFZEvent({
provider,
id: ds.id,
set: ds.set,
name: ds.name || target.alt,
source: event
});
@ -1504,6 +1653,18 @@ export default class Emotes extends Module {
}
}
// Check to see if this emote applies any effects with as_background.
/*let as_background = false;
if ( emote.modifier_flags ) {
for(const input of EFFECT_STYLES)
if ( (emote.modifier_flags & input.flags) === input.flags ) {
if ( input.as_background ) {
as_background = true;
break;
}
}
}*/
emote.token = {
type: 'emote',
id: emote.id,
@ -1524,7 +1685,8 @@ export default class Emotes extends Module {
length: emote.name.length,
height: emote.height,
width: emote.width,
source_modifier_flags: emote.modifier_flags ?? 0
source_modifier_flags: emote.modifier_flags ?? 0,
//effect_bg: as_background
};
if ( has(MODIFIERS, emote.id) )
@ -1746,7 +1908,7 @@ export default class Emotes extends Module {
}
if ( emote.modifier && emote.mask?.[1] ) {
output = (output || '') + `.modified-emote[data-modifiers~="${emote.id}"] > img {
output = (output || '') + `.modified-emote[data-modifiers~="${emote.id}"] > .chat-line__message--emote {
-webkit-mask-image: url("${emote.mask[1]}");
-webkit-mask-position: center center;
}`
@ -1790,9 +1952,10 @@ export default class Emotes extends Module {
this.__twitch_set_to_channel[set_id] = channel;
}
_getTwitchEmoteSet(emote_id) {
_getTwitchEmoteSet(emote_id, need_artist = false) {
const tes = this.__twitch_emote_to_set,
tsc = this.__twitch_set_to_channel;
tsc = this.__twitch_set_to_channel,
tsa = this.__twitch_emote_to_artist;
if ( typeof emote_id === 'number' ) {
if ( isNaN(emote_id) || ! isFinite(emote_id) )
@ -1801,7 +1964,7 @@ export default class Emotes extends Module {
emote_id = `${emote_id}`;
}
if ( has(tes, emote_id) ) {
if ( has(tes, emote_id) && (! need_artist || has(tsa, emote_id)) ) {
const val = tes[emote_id];
if ( Array.isArray(val) )
return new Promise(s => val.push(s));
@ -1829,6 +1992,10 @@ export default class Emotes extends Module {
if ( emote ) {
set_id = emote.setID;
if ( emote.id && ! has(tsa, emote.id) ) {
tsa[emote.id] = emote.artist;
}
if ( set_id && ! has(tsc, set_id) ) {
const type = determineEmoteType(emote);
@ -1860,6 +2027,28 @@ export default class Emotes extends Module {
return promise;
}
_getTwitchEmoteArtist(emote_id) {
const tsa = this.__twitch_emote_to_artist;
if ( has(tsa, emote_id) )
return Promise.resolve(tsa[emote_id]);
return this._getTwitchEmoteSet(emote_id, true)
.then(() => tsa[emote_id])
.catch(() => {
tsa[emote_id] = null;
return null;
});
}
getTwitchEmoteArtist(emote_id, callback) {
const promise = this._getTwitchEmoteArtist(emote_id);
if ( callback )
promise.then(callback);
else
return promise;
}
_getTwitchSetChannel(set_id) {
const tsc = this.__twitch_set_to_channel;

View file

@ -1864,7 +1864,7 @@ export default class Chat extends Module {
className: 'chat-author__intl-login'
}, ` (${user.login})`));
return [out];
return out;
}

View file

@ -7,17 +7,16 @@
import {sanitize, createElement} from 'utilities/dom';
import {has, getTwitchEmoteURL, split_chars, getTwitchEmoteSrcSet} from 'utilities/object';
import {EmoteTypes, REPLACEMENT_BASE, REPLACEMENTS} from 'utilities/constants';
import {EmoteTypes, REPLACEMENT_BASE, REPLACEMENTS, WEIRD_EMOTE_SIZES} from 'utilities/constants';
import {CATEGORIES, JOINER_REPLACEMENT} from './emoji';
import { MODIFIER_FLAGS } from './emotes';
const SHRINK_X = MODIFIER_FLAGS.ShrinkX,
STRETCH_X = MODIFIER_FLAGS.GrowX,
SHRINK_Y = MODIFIER_FLAGS.ShrinkY,
STRETCH_Y = MODIFIER_FLAGS.GrowY,
ROTATE_45 = MODIFIER_FLAGS.Rotate45,
ROTATE_90 = MODIFIER_FLAGS.Rotate90;
SLIDE_X = MODIFIER_FLAGS.Slide,
STRETCH_X = MODIFIER_FLAGS.GrowX;
//SHRINK_Y = MODIFIER_FLAGS.ShrinkY,
//STRETCH_Y = MODIFIER_FLAGS.GrowY,
const EMOTE_CLASS = 'chat-image chat-line__message--emote',
@ -1227,7 +1226,10 @@ export const AddonEmotes = {
effects = token.modifier_flags,
is_big = (token.big && ! token.can_big && token.height);
if ( effects || ml ) {
let as_bg = (this.emotes.activeAsBackgroundMask & effects) !== 0;
let no_wide = (this.emotes.activeNoWideMask & effects) !== 0;
if ( no_wide || effects || ml ) {
// We need to calculate the size of the emote and the biggest
// modifier so that everything can be nicely centered.
if ( token.provider === 'emoji' ) {
@ -1243,7 +1245,7 @@ export const AddonEmotes = {
height: size
};
} else {
const factor = big ? 2 : 1;
const factor = token.big ? 2 : 1;
style = {
width: token.width * factor,
height: token.height * factor
@ -1255,6 +1257,9 @@ export const AddonEmotes = {
}
for(const mod of mods) {
if ( mod.effect_bg )
as_bg = true;
if ( ! mod.hidden && mod.set !== 'info' ) {
const factor = mod.big ? 2 : 1,
width = mod.width * factor,
@ -1274,27 +1279,71 @@ export const AddonEmotes = {
style.width *= 0.5;
if ( (effects & STRETCH_X) === STRETCH_X )
style.width *= 2;
if ( (effects & SHRINK_Y) === SHRINK_Y )
/*if ( (effects & SHRINK_Y) === SHRINK_Y )
style.height *= 0.5;
if ( (effects & STRETCH_Y) === STRETCH_Y )
style.height *= 2;
style.height *= 2;*/
style.width = Math.min(style.width, big ? 256 : 128);
style.height = Math.min(style.height, big ? 80 : 40);
if ( style.width > outerStyle.width )
outerStyle.width = style.width;
if ( style.height > outerStyle.height )
outerStyle.height = style.height;
style.width = Math.min(style.width, token.big ? 256 : 128);
style.height = Math.min(style.height, token.big ? 80 : 40);
}
if ( no_wide ) {
const limit = token.big ? 64 : 32;
if ( style.width > limit ) {
const factor = limit / style.width;
style.width *= factor;
style.height *= factor;
}
}
if ( style.width > outerStyle.width )
outerStyle.width = style.width;
if ( style.height > outerStyle.height )
outerStyle.height = style.height;
if ( style.width !== outerStyle.width )
style.marginLeft = (outerStyle.width - style.width) / 2;
if ( style.height !== outerStyle.height )
style.marginTop = (outerStyle.height - style.height) / 2;
if ( effects ) {
if ( (effects & SLIDE_X) === SLIDE_X ) {
style['--ffz-width'] = `${style.width}px`;
style['--ffz-speed-x'] = `${0.5 * (style.width / (token.big ? 64 : 32))}s`;
}
}
}
const emote = (<img
let emote;
if ( as_bg ) {
style = style || {};
style.backgroundImage = `url("${src}")`;
style.backgroundSize = '100%';
emote = (<div
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip${hoverSrc ? ' ffz-hover-emote' : ''}${token.provider === 'twitch' ? ' twitch-emote' : token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`}
style={style}
data-name={token.text}
data-tooltip-type="emote"
data-provider={token.provider}
data-id={token.id}
data-set={token.set}
data-code={token.code}
data-variant={token.variant}
data-normal-src={normalSrc}
data-normal-src-set={normalSrcSet}
data-hover-src={hoverSrc}
data-hover-src-set={hoverSrcSet}
data-modifiers={ml ? mods.map(x => x.id).join(' ') : null}
data-modifier-info={ml ? JSON.stringify(mods.map(x => [x.set, x.id])) : null}
onClick={this.emotes.handleClick}
><div class="ffz-alt-text">{ token.text }</div></div>);
}
else
emote = (<img
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip${hoverSrc ? ' ffz-hover-emote' : ''}${token.provider === 'twitch' ? ' twitch-emote' : token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`}
src={src}
srcSet={srcSet}
@ -1332,15 +1381,19 @@ export const AddonEmotes = {
style={outerStyle}
data-modifiers={ml ? mods.map(x => x.id).join(' ') : null}
data-effects={effects ? effects : undefined}
onClick={this.emotes.handleClick}
//onClick={this.emotes.handleClick}
>
{emote}
{mods.map(t => {
if ( (t.source_modifier_flags & 1) === 1 || t.set === 'info')
if (t.set === 'info')
return null;
return <span key={t.text}>
{this.tokenizers.emote.render.call(this, t, createElement, true)}
</span>
if ((t.source_modifier_flags & 1) === 1 && t.text)
return null;
// This is currently weird and breaks copy/paste
// so since it doesn't *fix* copy/paste just leave
// it out for now.
//return <div class="ffz-alt-text">{` ${t.text}`}</div>;
return <span key={t.text}>{this.tokenizers.emote.render.call(this, t, createElement, true)}</span>
})}
</div>);
},
@ -1350,7 +1403,7 @@ export const AddonEmotes = {
provider = ds.provider,
modifiers = ds.modifierInfo;
let name, preview, source, owner, mods, fav_source, emote_id,
let name, preview, source, artist, owner, mods, fav_source, emote_id,
plain_name = false;
const hide_source = ds.noSource === 'true';
@ -1377,11 +1430,15 @@ export const AddonEmotes = {
if ( provider === 'twitch' ) {
emote_id = ds.id;
const set_id = hide_source ? null : await this.emotes.getTwitchEmoteSet(emote_id),
emote_set = set_id != null && await this.emotes.getTwitchSetChannel(set_id);
emote_set = set_id != null && await this.emotes.getTwitchSetChannel(set_id),
raw_artist = hide_source ? null : await this.emotes.getTwitchEmoteArtist(emote_id);
preview = `${getTwitchEmoteURL(ds.id, 4, true, true)}?_=preview`;
fav_source = 'twitch';
if ( raw_artist )
artist = raw_artist.displayName || raw_artist.login;
if ( emote_set ) {
const type = emote_set.type;
if ( type === EmoteTypes.Global ) {
@ -1435,6 +1492,9 @@ export const AddonEmotes = {
if ( emote ) {
emote_id = emote.id;
if ( emote.artist )
artist = emote.artist.display_name || emote.artist.name;
if ( emote.owner )
owner = this.i18n.t(
'emote.owner', 'By: {owner}',
@ -1467,6 +1527,15 @@ export const AddonEmotes = {
height: (target.height ?? 28) * 2
};
let outerStyle = {
width: style.width,
height: style.height
};
let as_bg = (this.emotes.activeAsBackgroundMask & effects) !== 0;
let no_wide = (this.emotes.activeNoWideMask & effects) !== 0;
let changed = false;
if ( (effects & SHRINK_X) === SHRINK_X ) {
@ -1477,14 +1546,14 @@ export const AddonEmotes = {
style.width *= 2;
changed = true;
}
if ( (effects & SHRINK_Y) === SHRINK_Y ) {
/*if ( (effects & SHRINK_Y) === SHRINK_Y ) {
style.height *= 0.5;
changed = true;
}
if ( (effects & STRETCH_Y) === STRETCH_Y ) {
style.height *= 2;
changed = true;
}
}*/
if ( changed ) {
if ( style.width > 512 )
@ -1493,9 +1562,41 @@ export const AddonEmotes = {
style.height = 160;
}
if ( no_wide ) {
const limit = 64;
if ( style.width > limit ) {
const factor = limit / style.width;
style.width *= factor;
style.height *= factor;
}
}
if ( style.width > outerStyle.width )
outerStyle.width = style.width;
if ( style.height > outerStyle.height )
outerStyle.height = style.height;
if ( style.width !== outerStyle.width )
style.marginLeft = (outerStyle.width - style.width) / 2;
if ( style.height !== outerStyle.height )
style.marginTop = (outerStyle.height - style.height) / 2;
if ( (effects & SLIDE_X) === SLIDE_X ) {
style['--ffz-width'] = `${style.width}px`;
style['--ffz-speed-x'] = `${0.5 * style.width / 64}s`;
}
style.width = `${style.width}px`;
style.height = `${style.height}px`;
outerStyle.width = `${outerStyle.width}px`;
outerStyle.height = `${outerStyle.height}px`;
if ( as_bg ) {
style.backgroundImage = `url("${target.src}")`;
style.backgroundSize = '100%';
}
// Whip up a special preview.
preview = (<div class="ffz-effect-tip">
<img
@ -1508,18 +1609,24 @@ export const AddonEmotes = {
<span class="ffz-i-right-open"></span>
<div
class={`ffz--inline ffz--pointer-events modified-emote${style ? ' scaled-modified-emote' : ''}`}
style={style}
style={outerStyle}
data-modifiers={emote.id}
data-effects={effects}
>
<img
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip ffz-emote`}
src={target.src}
srcSet={target.srcSet}
style={style}
height={style ? undefined : `${target.height * 2}px`}
onLoad={tip.update}
/>
{as_bg
? <div
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip ffz-emote`}
style={style}
/>
: <img
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip ffz-emote`}
src={target.src}
srcSet={target.srcSet}
style={style}
height={style ? undefined : `${target.height * 2}px`}
onLoad={tip.update}
/>
}
</div>
</div>);
}
@ -1572,6 +1679,13 @@ export const AddonEmotes = {
{owner}
</div>),
artist && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05">
{this.i18n.t(
'emote.artist', 'Artist: {artist}',
{artist}
)}
</div>),
ds.sellout && (<div class="tw-mg-t-05 tw-border-t tw-pd-t-05">{ds.sellout}</div>),
mods && (<div class="tw-pd-t-1 tw-pd-b-05">{mods}</div>),
@ -1901,6 +2015,11 @@ export const TwitchEmotes = {
}
}
const sizes = WEIRD_EMOTE_SIZES[e_id];
const width = sizes ? sizes[0] : 28,
height = sizes ? sizes[1] : 28;
out.push({
type: 'emote',
id: e_id,
@ -1916,8 +2035,8 @@ export const TwitchEmotes = {
anim,
big,
can_big,
width: 28,
height: 28, // Not always accurate but close enough.
width,
height,
text: text.slice(e_start - t_start, e_end - t_start).join(''),
modifiers: [],
modifier_flags: 0

View file

@ -0,0 +1,338 @@
<template>
<div
:style="{zIndex: z}"
class="ffz-viewer-card tw-border-radius-medium tw-c-background-base tw-c-text-base tw-elevation-2 tw-flex tw-flex-column viewer-card"
tabindex="0"
@focusin="onFocus"
@keyup.esc="close"
>
<div
class="ffz-viewer-card__header tw-c-background-accent-alt tw-flex-grow-0 tw-flex-shrink-0 viewer-card__background tw-relative"
>
<div class="tw-flex tw-flex-column tw-full-height tw-full-width viewer-card__overlay">
<div class="tw-align-center tw-align-items-center tw-c-background-alt tw-c-text-base tw-flex tw-flex-grow-1 tw-flex-row tw-full-width tw-justify-content-start tw-pd-05 tw-relative viewer-card__banner">
<div class="tw-mg-l-05 tw-mg-y-05 tw-inline-flex viewer-card-drag-cancel">
<figure class="ffz-avatar" :style="imageStyle">
<img
v-if="loaded && emote.src"
:src="emote.src"
class="tw-block tw-image tw-image-avatar"
>
</figure>
</div>
<div class="tw-align-left tw-flex-grow-1 tw-ellipsis tw-mg-x-1 tw-mg-y-05 viewer-card__display-name">
<h4 class="tw-inline" :title="emote ? emote.name : raw_emote.name">
{{ emote ? emote.name : raw_emote.name }}
</h4>
<p
v-if="loaded && emote.source"
class="tw-c-text-alt-2 tw-font-size-6"
:title="emote.source_i18n ? t(emote.source_i18n, emote.source) : emote.source"
>
{{ emote.source_i18n ? t(emote.source_i18n, emote.source) : emote.source }}
</p>
<p v-if="loaded && emote.owner" class="tw-c-text-alt-2 tw-font-size-6">
<t-list
phrase="emote-card.owner"
default="Owner: {owner}"
>
<template #owner>
<a
v-if="emote.ownerLink"
rel="noopener noreferrer"
target="_blank"
:href="emote.ownerLink"
>{{ emote.owner }}</a>
<span v-else>{{ emote.owner }}</span>
</template>
</t-list>
</p>
<p v-if="loaded && emote.artist" class="tw-c-text-alt-2 tw-font-size-6">
<t-list
phrase="emote-card.artist"
default="Artist: {artist}"
>
<template #artist>
<a
v-if="emote.artistLink"
rel="noopener noreferrer"
target="_blank"
:href="emote.artistLink"
class="ffz-i-artist"
>{{ emote.artist }}</a>
<span v-else>{{ emote.artist }}</span>
</template>
</t-list>
</p>
</div>
<div class="tw-flex tw-flex-column tw-align-self-start">
<button
:data-title="t('emote-card.close', 'Close')"
:aria-label="t('emote-card.close', 'Close')"
class="viewer-card-drag-cancel tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="close"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-cancel" />
</span>
</button>
<div
v-if="hasMoreActions"
v-on-clickaway="closeMore"
class="tw-relative viewer-card-drag-cancel"
>
<button
:data-title="t('emote-card.more', 'More')"
:aria-label="t('emote-card.more', 'More')"
class="tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="toggleMore"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-ellipsis-vert" />
</span>
</button>
<balloon
v-if="moreOpen"
color="background-alt-2"
dir="down-right"
size="sm"
>
<simplebar classes="ffz-mh-30">
<div class="tw-pd-y-1">
<template v-for="(entry, idx) in moreActions">
<div
v-if="entry.divider"
:key="idx"
class="tw-mg-1 tw-border-b"
/>
<a
:key="idx"
:disabled="entry.disabled"
:href="entry.href"
rel="noopener noreferrer"
target="_blank"
class="tw-block ffz-interactable ffz-interactable--hover-enabled ffz-interactable--default tw-interactive tw-full-width ffz--cursor"
@click="clickMore(entry, $event)"
>
<div class="tw-flex tw-align-items-center tw-pd-y-05 tw-pd-x-1">
<div
class="tw-flex-grow-1"
:class="{'tw-mg-r-1' : !! entry.icon}"
>
{{ entry.title_i18n ? t(entry.title_i18n, entry.title, entry) : entry.title }}
</div>
<figure
v-if="entry.icon || entry.type === 'link'"
:class="entry.icon || 'ffz-i-link-ext'"
/>
</div>
</a>
</template>
</div>
</simplebar>
</balloon>
</div>
</div>
</div>
</div>
</div>
<ReportForm
v-if="reporting"
:emote="emote"
:getFFZ="getFFZ"
@close="close"
/>
</div>
</template>
<script>
import ReportForm from './report-form.vue';
import displace from 'displacejs';
export default {
components: {
ReportForm
},
props: [
'raw_emote', 'data',
'pos_x', 'pos_y',
'getZ', 'getFFZ'
],
data() {
return {
z: this.getZ(),
moreOpen: false,
reporting: false,
loaded: false,
errored: false,
pinned: false,
emote: null
}
},
computed: {
moreActions() {
if ( ! this.loaded || ! this.emote.more )
return null;
return this.emote.more;
},
hasMoreActions() {
return (this.moreActions?.length ?? 0) > 0;
},
imageStyle() {
if ( ! this.loaded )
return {};
return {
width: `${Math.min(112, (this.emote.width ?? 28) * 2)}px`,
height: `${(this.emote.height ?? 28) * 2}px`
};
}
},
beforeMount() {
this.ffzEmit(':open', this);
this.data.then(data => {
this.loaded = true;
this.ffzEmit(':load', this);
this.emote = data;
}).catch(err => {
console.error('Error loading emote card data', err);
this.errored = true;
});
},
mounted() {
this._on_resize = this.handleResize.bind(this);
window.addEventListener('resize', this._on_resize);
this.createDrag();
},
beforeDestroy() {
this.ffzEmit(':close', this);
this.destroyDrag();
if ( this._on_resize ) {
window.removeEventListener('resize', this._on_resize);
this._on_resize = null;
}
},
methods: {
toggleMore() {
this.moreOpen = ! this.moreOpen;
},
closeMore() {
this.moreOpen = false;
},
clickMore(entry, evt) {
this.moreOpen = false;
if ( entry.type === 'link' )
return;
evt.preventDefault();
if ( entry.type === 'report-ffz' )
this.reporting = true;
},
constrain() {
const el = this.$el;
let parent = el.parentElement,
moved = false;
if ( ! parent )
parent = document.body;
const box = el.getBoundingClientRect(),
pbox = parent.getBoundingClientRect();
if ( box.top < pbox.top ) {
el.style.top = `${el.offsetTop + (pbox.top - box.top)}px`;
moved = true;
} else if ( box.bottom > pbox.bottom ) {
el.style.top = `${el.offsetTop - (box.bottom - pbox.bottom)}px`;
moved = true;
}
if ( box.left < pbox.left ) {
el.style.left = `${el.offsetLeft + (pbox.left - box.left)}px`;
moved = true;
} else if ( box.right > pbox.right ) {
el.style.left = `${el.offsetLeft - (box.right - pbox.right)}px`;
moved = true;
}
if ( moved && this.displace )
this.displace.reinit();
},
pin() {
this.pinned = true;
this.$emit('pin');
this.ffzEmit(':pin', this);
},
cleanTips() {
this.$nextTick(() => this.ffzEmit('tooltips:cleanup'))
},
close() {
this.$emit('close');
},
createDrag() {
this.$nextTick(() => {
this.displace = displace(this.$el, {
handle: this.$el.querySelector('.ffz-viewer-card__header'),
highlightInputs: true,
constrain: true,
ignoreFn: e => e.target.closest('.viewer-card-drag-cancel') != null
});
})
},
destroyDrag() {
if ( this.displace ) {
this.displace.destroy();
this.displace = null;
}
},
handleResize() {
if ( this.displace )
this.displace.reinit();
},
onFocus() {
this.z = this.getZ();
},
focus() {
this.$el.focus();
},
ffzEmit(event, ...args) {
this.$emit('emit', event, ...args);
}
}
}
</script>

View file

@ -0,0 +1,257 @@
<template>
<section class="viewer-card__actions tw-bottom-0 tw-pd-1">
<template v-if="loading">
<div class="tw-align-center tw-pd-1">
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
</div>
</template>
<template v-else-if="errorNoUser">
<div class="tw-align-center tw-pd-1">
<div class="tw-mg-t-1 tw-mg-b-2">
<img
src="//cdn.frankerfacez.com/emoticon/26608/2"
srcSet="//cdn.frankerfacez.com/emoticon/26608/2 1x, //cdn.frankerfacez.com/emoticon/26608/4 2x"
>
</div>
{{ t('emote-card.report.no-user', 'Sorry, but you don\'t appear to have a FrankerFaceZ account or you aren\'t signed in. In order to submit a report, you need to have a FFZ account.') }}
</div>
</template>
<template v-else-if="error">
<div class="tw-align-center tw-pd-1">
<div class="tw-mg-t-1 tw-mg-b-2">
<img
src="//cdn.frankerfacez.com/emoticon/26608/2"
srcSet="//cdn.frankerfacez.com/emoticon/26608/2 1x, //cdn.frankerfacez.com/emoticon/26608/4 2x"
>
</div>
{{ t('emote-card.report.error', 'There was an error submitting your report.') }}
</div>
</template>
<template v-else-if="success">
<div class="tw-align-center tw-pd-1">
{{ t('emote-card.report.success', 'Your report was submitted successfully.') }}
</div>
<div class="tw-align-center">
<button
class="tw-button tw-mg-x-1"
@click="$emit('close')"
>
<span class="tw-button__text">
{{ t('emote-card.close', 'Close') }}
</span>
</button>
</div>
</template>
<template v-else-if="category">
<p class="tw-strong tw-mg-b-05">
<t-list
phrase="emote-card.report-details"
default="You are reporting this emote for {reason}. Please enter any additional details below:"
>
<template #reason><code>{{ category.i18n ? t(category.i18n, category.title, category) : category.title }}</code></template>
</t-list>
</p>
<textarea
v-model="message"
class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input"
:placeholder="t('emote-card.report.placeholder', 'Enter a report message here.')"
/>
<div class="tw-mg-t-05 tw-align-center">
<button
:disabled="! canReport"
class="tw-button tw-mg-x-1"
:class="{'tw-button--disabled': ! canReport}"
@click="submitReport"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-flag" />
</span>
<span class="tw-button__text">
{{ t('emote-card.report', 'Report Emote') }}
</span>
</button>
</div>
</template>
<template v-else>
<p class="tw-strong tw-mg-b-1">
{{ t('emote-card.report-why', 'Why are you submitting this report?') }}
</p>
<form class="tw-flex tw-flex-column tw-border tw-c-background-body tw-border-radius-small tw-full-width">
<div
v-for="(reason, idx) in REASONS"
:key="idx"
class="ffz-radio tw-relative tw-pd-l-1"
:class="{'tw-border-t': idx > 0}"
>
<input
:id="'report$' + id + '$reason$' + idx"
:name="'report-reasons$' + id"
v-model="pendingCategory"
:value="reason"
type="radio"
class="ffz-radio__input"
/>
<label
:for="'report$' + id + '$reason$' + idx"
class="tw-block ffz-radio__label tw-pd-r-1 tw-pd-y-1"
>
<div class="tw-pd-l-1">
{{ reason.i18n ? t(reason.i18n, reason.title, reason) : reason.title }}
</div>
</label>
</div>
</form>
<div class="tw-mg-t-05 tw-align-center">
<button
:disabled="pendingCategory == null"
class="tw-button tw-mg-x-1"
:class="{'tw-button--disabled': pendingCategory == null}"
@click="category = pendingCategory"
>
<span class="tw-button__text">
{{ t('emote-card.report.next', 'Next') }}
</span>
</button>
</div>
</template>
</section>
</template>
<script>
const REASONS = [
{
title: 'Bullying or Harassment',
i18n: 'emote-card.report.bully-harass'
},
{
title: 'Hateful Conduct',
i18n: 'emote-card.report.hateful'
},
{
title: 'Nudity or Sexually Explicit',
i18n: 'emote-card.report.explicit'
},
{
title: 'Other',
i18n: 'emote-card.report.other',
skip_report: true
}
];
let id = 0;
export default {
props: [
'emote',
'getFFZ'
],
data() {
return {
REASONS: REASONS,
id: id++,
message: '',
pendingCategory: null,
category: null,
loading: false,
success: false,
error: false,
errorNoUser: false
}
},
computed: {
canReport() {
if ( ! this.category )
return false;
if ( this.category.skip_report )
return ! /^\s*$/.test(this.message)
return true;
}
},
created() {
this.checkToken();
},
methods: {
async checkToken() {
this.loading = true;
let token;
try {
token = await this.getFFZ().resolve('socket').getAPIToken();
} catch(err) {
console.error(err);
token = null;
}
this.loading = false;
this.errorNoUser = token == null;
},
async submitReport() {
if ( this.loading || ! this.canReport )
return;
this.loading = true;
try {
await this._submitReport();
} catch(err) {
console.error(err);
this.loading = false;
this.error = true;
this.success = false;
return;
}
this.loading = false;
this.success = true;
},
async _submitReport() {
const token = await this.getFFZ().resolve('socket').getAPIToken();
if ( ! token?.token )
throw new Error('Unable to get token');
const server = this.getFFZ().resolve('staging').api;
const params = new URLSearchParams;
let msg = this.message;
if ( this.category && ! this.category.skip_report )
msg = `${this.category.title}${msg.length ? `\r\nDetails: ${msg}` : ''}`;
params.append('report', msg);
const resp = await fetch(`${server}/v2/emote/${this.emote.id}/report`, {
method: 'POST',
body: params,
headers: {
Authorization: `Bearer ${token.token}`
}
});
if ( ! resp || ! resp.ok )
throw new Error('Invalid response from server.');
const data = await resp.json();
if ( ! data?.success )
throw new Error('Did not succeed');
}
}
}
</script>

View file

@ -0,0 +1,343 @@
'use strict';
// ============================================================================
// Emote Cards
// ============================================================================
import {createElement, sanitize} from 'utilities/dom';
import {has, maybe_call, deep_copy, getTwitchEmoteURL} from 'utilities/object';
import { EmoteTypes } from 'utilities/constants';
import GET_EMOTE from './twitch_data.gql';
import Module from 'utilities/module';
function getEmoteTypeFromTwitchType(type) {
if ( type === 'SUBSCRIPTIONS' )
return EmoteTypes.Subscription;
if ( type === 'FOLLOWER' )
return EmoteTypes.Follower;
if ( type === 'GLOBALS' || type === 'SMILIES' )
return EmoteTypes.Global;
if ( type === 'LIMITED_TIME' || type === 'MEGA_COMMERCE' )
return EmoteTypes.LimitedTime;
if ( type === 'BITS_BADGE_TIERS' )
return EmoteTypes.BitsTier;
if ( type === 'TWO_FACTOR' )
return EmoteTypes.TwoFactor;
if ( type === 'PRIME' )
return EmoteTypes.Prime;
if ( type === 'TURBO' )
return EmoteTypes.Turbo;
return EmoteTypes.Unknown;
}
export default class EmoteCard extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('i18n');
this.inject('chat');
this.inject('chat.emotes');
this.inject('site');
this.inject('site.apollo');
this.inject('site.twitch_data');
this.vue = this.resolve('vue');
this.last_z = 9000;
this.open_cards = {};
this.last_card = null;
}
onEnable() {
this.on('chat.emotes:click', this.handleClick, this);
}
handleClick(evt) {
if ( ! this.chat.context.get('chat.emote-dialogs') )
return;
evt.preventDefault();
this.openCard({
provider: evt.provider,
set: evt.set,
id: evt.id
}, evt.source);
}
async loadVue() {
if ( this._vue_loaded )
return;
await this.vue.enable();
const card_component = await import('./components/card.vue');
this.vue.component('emote-card', card_component.default);
this._vue_loaded = true;
}
async loadData(emote) {
if ( emote.provider === 'twitch' ) {
const apollo = this.resolve('site.apollo');
if ( ! apollo )
throw new Error('Unable to load emote data');
const result = await apollo.client.query({
query: GET_EMOTE,
variables: {
emoteID: emote.id
}
});
if ( ! result?.data?.emote )
throw new Error('Unable to load emote data');
const data = result.data.emote;
const src = getTwitchEmoteURL(data.id, 2, true, true);
const srcSet = `${src} 1x, ${getTwitchEmoteURL(data.id, 4, true, true)} 2x`;
let source;
//console.log("loaded data", data);
const type = getEmoteTypeFromTwitchType(data.type);
if ( type === EmoteTypes.Subscription ) {
const products = data.owner?.subscriptionProducts;
let tier;
if ( Array.isArray(products) ) {
for(const product of products) {
if ( product.emotes.some(em => em.id === data.id) ) {
tier = product.tier;
break;
}
}
}
if ( tier === '1000' )
tier = 1;
else if ( tier === '2000' )
tier = 2;
else if ( tier === '3000' )
tier = 3;
else
tier = 1;
source = this.i18n.t('emote-card.sub', 'Tier {tier} Sub Emote ({source})', {
tier: tier,
source: data.owner.displayName || data.owner.login
});
} else if ( type === EmoteTypes.Follower )
source = this.i18n.t('emote.follower', 'Follower Emote ({source})', {
source: data.owner.displayName || data.owner.login
});
else if ( type === EmoteTypes.Global )
source = this.i18n.t('emote.global', 'Twitch Global');
else if ( type === EmoteTypes.LimitedTime )
source = this.i18n.t('emote.limited', 'Limited-Time Only Emote');
else if ( type === EmoteTypes.BitsTier ) {
source = this.i18n.t('emote-card.bits', '{amount,number} Bits Reward ({source})', {
amount: data.bitsBadgeTierSummary?.threshold,
source: data.owner.displayName || data.owner.login
});
} else if ( type === EmoteTypes.TwoFactor )
source = this.i18n.t('emote.2fa', 'Twitch 2FA Emote');
else if ( type === EmoteTypes.ChannelPoints )
source = this.i18n.t('emote.points', 'Channel Points Emote');
else if ( type === EmoteTypes.Prime || type === EmoteTypes.Turbo )
source = this.i18n.t('emote.prime', 'Prime Gaming');
else
source = data.type;
const out = {
//raw: data,
id: data.id,
more: [],
src,
srcSet,
name: data.token,
source,
artist: data.artist
? (data.artist.displayName || data.artist.login)
: null,
artistLink: data.artist
? `https://www.twitch.tv/${data.artist.login}`
: null
};
/*if ( data.owner?.id )
out.more.push({
type: 'link',
icon: 'ffz-i-link-ext',
title: 'View Channel on TwitchEmotes.com',
href: `https://twitchemotes.com/channels/${data.owner.id}`
});*/
return out;
}
// Try to get the emote set.
const emote_set = this.emotes.emote_sets[emote.set],
data = emote_set?.emotes?.[emote.id];
if ( ! data )
throw new Error('Unable to load emote data');
const out = {
id: data.id,
more: [],
src: data.animSrc2 ?? data.src2,
srcSet: data.animSrcSet2 ?? data.srcSet2,
width: data.width,
height: data.height,
name: data.name,
source: emote_set.source_line || (`${emote_set.source || 'FFZ'} ${emote_set.title || 'Global Emotes'}`),
owner: data.owner
? (data.owner.display_name || data.owner.name)
: null,
ownerLink: data.owner
? `https://www.frankerfacez.com/${data.owner.name}`
: null,
artist: data.artist
? (data.artist.display_name || data.artist.name)
: null,
artistLink: data.artist
? `https://www.frankerfacez.com/${data.artist.name}`
: null,
};
if ( ! emote_set.source ) {
out.more.push({
type: 'link',
title_i18n: 'emote-card.view-on-ffz',
title: 'View on FrankerFaceZ',
href: `https://www.frankerfacez.com/emoticon/${data.id}-`
});
out.more.push({
type: 'report-ffz',
title_i18n: 'emote-card.report',
title: 'Report Emote',
icon: 'ffz-i-flag'
});
}
return out;
}
async openCard(emote, event) {
const card_key = `${emote.provider}::${emote.id}`,
old_card = this.open_cards[card_key];
if ( old_card ) {
old_card.$el.style.zIndex = ++this.last_z;
old_card.focus();
return;
}
let pos_x = event ? event.clientX : window.innerWidth / 2,
pos_y = event ? event.clientY + 15 : window.innerHeight / 2;
if ( this.last_card ) {
const card = this.last_card;
if ( ! event ) {
pos_x = card.$el.offsetLeft;
pos_y = card.$el.offsetTop;
}
card.close();
}
// Start loading data. Don't await it yet, so we can
// wait for Vue at the same time.
const data = this.loadData(emote);
// Now load vue.
await this.loadVue();
// Display the card.
this.last_card = this.open_cards[card_key] = this.buildCard(
pos_x,
pos_y,
emote,
data
);
}
buildCard(pos_x, pos_y, emote, data) {
let child;
const component = new this.vue.Vue({
el: createElement('div'),
render: h => h('emote-card', {
props: {
raw_emote: deep_copy(emote),
data: data,
getFFZ: () => this,
getZ: () => ++this.last_z
},
on: {
emit: (event, ...data) => this.emit(event, ...data),
close: () => {
const el = component.$el;
el.remove();
component.$destroy();
if ( this.last_card === child )
this.last_card = null;
const card_key = `${emote.provider}::${emote.id}`;
if ( this.open_cards[card_key] === child )
this.open_cards[card_key] = null;
this.emit('tooltips:cleanup');
},
pin: () => {
if ( this.last_card === child )
this.last_card = null;
}
}
})
});
child = component.$children[0];
const el = component.$el;
el.style.left = `${pos_x}px`;
el.style.top = `${pos_y}px`;
const container = document.querySelector(this.site.constructor.DIALOG_SELECTOR ?? '#root>div>.tw-full-height,.twilight-minimal-root>.tw-full-height');
container.appendChild(el);
requestAnimationFrame(() => child.constrain());
return child;
}
}

View file

@ -0,0 +1,65 @@
query FFZ_EmoteCard ($emoteID: ID!) {
emote(id: $emoteID) {
id
type
token
setID
artist {
id
login
displayName
}
owner {
id
login
displayName
channel {
id
localEmoteSets {
id
emotes {
id
token
}
}
}
stream {
id
type
}
self {
follower {
followedAt
}
subscriptionBenefit {
id
tier
}
}
subscriptionProducts {
id
displayName
tier
name
url
emotes {
id
token
}
priceInfo {
id
currency
price
}
}
}
bitsBadgeTierSummary {
threshold
self {
isUnlocked
numberOfBitsUntilUnlock
}
}
type
}
}

View file

@ -580,11 +580,15 @@ export default {
if ( def.title ) {
const data = this.getData(),
out = maybe_call(def.title, this, data, def),
i18n = def.title_i18n || `chat.actions.${this.display.action}`;
out = maybe_call(def.title, this, data, def);
let i18n = maybe_call(def.title_i18n, this, data, def);
if ( i18n === undefined )
i18n = `chat.actions.${this.display.action}`;
if ( out )
if ( out && i18n )
return this.t(i18n, out, data);
else if ( out )
return out;
}
return this.t('setting.actions.untitled', 'Action: {action}', this.display);
@ -614,11 +618,15 @@ export default {
return null;
const data = this.getData(),
out = maybe_call(def.description, this, data, def),
i18n = def.description_i18n || `chat.actions.${this.display.action}.desc`;
out = maybe_call(def.description, this, data, def);
let i18n = maybe_call(def.description_i18n, this, data, def);
if ( i18n === undefined )
i18n = `chat.actions.${this.display.action}.desc`;
if ( out )
if ( out && i18n )
return this.t(i18n, out, data);
else if ( out )
return out;
return null;
},

View file

@ -9,6 +9,7 @@
v-for="rule in editing"
:key="rule.id"
:value="rule"
:disabled="disabled"
:filters="filters"
:preview="preview"
:context="context"
@ -61,6 +62,10 @@ export default {
props: {
value: Array,
filters: Object,
disabled: {
type: Boolean,
default: false
},
maxRules: {
type: Number,
required: false,
@ -87,6 +92,8 @@ export default {
computed: {
canAddRule() {
if ( this.disabled )
return false;
return ! this.maxRules || (this.editing.length < this.maxRules);
}
},
@ -99,7 +106,7 @@ export default {
editing: {
handler() {
if (!this.resetting)
if ( ! this.resetting && ! this.disabled)
this.$emit('input', this.editing)
this.resetting = false;
},
@ -139,6 +146,11 @@ export default {
},
onAdd: event => {
if ( this.disabled ) {
event.preventDefault();
return;
}
if ( ! this.canAddRule ) {
event.preventDefault();
return;
@ -156,6 +168,11 @@ export default {
},
onRemove: event => {
if ( this.disabled ) {
event.preventDefault();
return;
}
let rule;
try {
rule = JSON.parse(event.originalEvent.dataTransfer.getData('JSON'));
@ -168,6 +185,9 @@ export default {
},
onUpdate: event => {
if ( this.disabled )
return;
if ( event.newIndex === event.oldIndex )
return;
@ -210,6 +230,9 @@ export default {
},
addRule() {
if ( this.disabled )
return;
this.adding = false;
const key = this.$refs.add_box.value,
@ -228,6 +251,9 @@ export default {
},
updateRule(id, data) {
if ( this.disabled )
return;
for(let i=0; i < this.editing.length; i++) {
if ( this.editing[i].id === id ) {
this.editing[i] = Object.assign(this.editing[i], data);
@ -237,6 +263,9 @@ export default {
},
deleteRule(id) {
if ( this.disabled )
return;
for(let i=0; i < this.editing.length; i++) {
if ( this.editing[i].id === id ) {
this.editing.splice(i, 1);

View file

@ -4,7 +4,11 @@
<h5 class="ffz-i-ellipsis-vert" />
</div>
<div v-if="! component" class="tw-flex tw-flex-grow-1 tw-align-self-center tw-align-items-center">
{{ t('setting.filters.missing', 'This rule of type "{type}" cannot be loaded. It may be from an add-on that is not loaded.', {type: editing && editing.type}) }}
</div>
<component
v-else
:is="component"
v-model="editing"
:type="type"
@ -111,7 +115,7 @@ export default {
},
isShort() {
return this.type && ! this.type.tall;
return ! this.component || (this.type && ! this.type.tall);
}
},

View file

@ -7,6 +7,23 @@
<template v-if="i !== item">&raquo; </template>
</span>
</header>
<section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
<h3 class="ffz-i-attention">
{{ t('setting.profiles.ephemeral', "This profile is ephemeral.") }}
</h3>
<span>
{{ t('setting.profiles.ephemeral.description',
"The currently selected profile is ephemeral, which is a fancy way of saying that it was automatically generated, that it only exists temporarily, and that any changes you make won't be saved."
) }}
</span>
<span>{{ t('setting.profiles.ephemeral.description-2',
"Please select a different profile from the selector at the upper left of this menu to edit your settings."
) }}</span>
</div>
</section>
<section v-if="(! context.currentProfile.live || ! context.currentProfile.toggled) && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
<h3 class="ffz-i-attention">

View file

@ -1,9 +1,29 @@
<template lang="html">
<div class="ffz--profile-editor">
<section v-if="isEphemeral" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
<h3 class="ffz-i-attention">
{{ t('setting.profiles.ephemeral', "This profile is ephemeral.") }}
</h3>
<span>
{{ t('setting.profiles.ephemeral.description',
"The currently selected profile is ephemeral, which is a fancy way of saying that it was automatically generated, that it only exists temporarily, and that any changes you make won't be saved."
) }}
</span>
<span>{{ t('setting.profiles.ephemeral.description-2',
"Please select a different profile from the selector at the upper left of this menu to edit your settings."
) }}</span>
</div>
</section>
<div class="tw-flex tw-align-items-center tw-border-t tw-pd-1">
<div class="tw-flex-grow-1" />
<button
:disabled="isEphemeral"
class="tw-button tw-button--text"
:class="{'tw-button--disabled': isEphemeral}"
@click="save"
>
<span class="tw-button__text ffz-i-floppy">
@ -80,6 +100,7 @@
<input
id="ffz:editor:name"
ref="name"
:disabled="isEphemeral"
v-model="name"
class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input"
>
@ -93,6 +114,7 @@
<textarea
id="ffz:editor:description"
ref="desc"
:disabled="isEphemeral"
v-model="desc"
class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input"
/>
@ -107,6 +129,7 @@
<key-picker
id="ffz:editor:hotkey"
ref="hotkey"
:disabled="isEphemeral"
v-model="hotkey"
/>
</div>
@ -148,6 +171,7 @@
id="ffz:editor:update"
ref="update"
:checked="! pause"
:disabled="isEphemeral"
type="checkbox"
class="ffz-checkbox__input"
@change="onPauseChange"
@ -175,6 +199,7 @@
<filter-editor
v-model="rules"
:filters="filters"
:disabled="isEphemeral"
:context="test_context"
:preview="true"
/>
@ -219,6 +244,10 @@ export default {
},
computed: {
isEphemeral() {
return this.item.profile?.ephemeral ?? false
},
canExport() {
return this.item.profile != null
}

View file

@ -235,6 +235,16 @@
<span class="ffz-i-ellipsis-vert" />
</div>
<div
v-if="p.ephemeral"
class="tw-flex tw-flex-shrink-0 tw-align-items-center tw-mg-r-1 tw-relative ffz-il-tooltip__container tw-font-size-4"
>
<span class="ffz-i-user-secret" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-left">
{{ t('setting.profiles.ephemeral', 'This profile is ephemeral.') }}
</div>
</div>
<div
v-if="p.url"
class="tw-flex tw-flex-shrink-0 tw-align-items-center tw-mg-r-1 tw-relative ffz-il-tooltip__container tw-font-size-4"

View file

@ -52,6 +52,14 @@
@click="changeProfile(p)"
>
<div class="ffz--profile-row__icon-tray tw-flex">
<div
v-if="p.ephemeral"
class="ffz-il-tooltip__container ffz--profile-row__icon ffz-i-user-secret tw-relative"
>
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.profiles.ephemeral', 'This profile is ephemeral.') }}
</div>
</div>
<div
v-if="p.url"
:class="`ffz-il-tooltip__container ffz--profile-row__icon ffz-i-download-cloud tw-relative${p.pause_updates ? ' ffz-unmatched-item' : ''}`"

View file

@ -8,6 +8,7 @@
:id="item.full_key"
ref="control"
:checked="value"
:disabled="isReadOnly"
type="checkbox"
class="ffz-checkbox__input"
@change="onChange"
@ -39,7 +40,13 @@
</button>
<div class="ffz--reset-button">
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear">
<button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
@ -58,7 +65,11 @@
v-if="item.extra"
style="padding-left:2.5rem"
>
<component :is="item.extra.component" :context="context" :item="item" />
<component
:is="item.extra.component"
:context="context"
:item="item"
/>
</section>
</div>
</template>

View file

@ -12,6 +12,7 @@
<color-picker
:id="item.full_key"
ref="control"
:disabled="isReadOnly"
:alpha="alpha"
:open-up="openUp"
:nullable="true"
@ -38,7 +39,13 @@
</span>
</button>
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear">
<button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}

View file

@ -13,6 +13,7 @@
<select
:id="item.full_key"
ref="control"
:disabled="isReadOnly"
class="tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05"
@change="onChange"
>
@ -48,7 +49,7 @@
<input
ref="text"
:value="value"
:disabled="! isCustom"
:disabled="isReadOnly || ! isCustom"
class="ffz-mg-t-1p tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input"
@change="onTextChange"
>
@ -72,7 +73,13 @@
</span>
</button>
<button v-if="has_value" class="tw-mg-l-05 tw-mg-y-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear">
<button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-mg-y-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}

View file

@ -12,6 +12,7 @@
<key-picker
:id="item.full_key"
ref="control"
:disabled="isReadOnly"
:value="value"
class="tw-mg-05"
@input="onInput"
@ -35,7 +36,13 @@
</span>
</button>
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear">
<button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}

View file

@ -12,6 +12,7 @@
<select
:id="item.full_key"
ref="control"
:disabled="isReadOnly"
:multiple="item.multiple || false"
:size="item.size || 0"
class="tw-border-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05 tw-mg-05"
@ -62,7 +63,13 @@
</span>
</button>
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear">
<button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}

View file

@ -15,6 +15,7 @@
:type="type"
:placeholder="placeholder"
:value="value"
:disabled="isReadOnly"
:class="{'ffz-input--error': ! isValid}"
class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-mg-05 ffz-input"
@change="onChange"
@ -38,7 +39,13 @@
</span>
</button>
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear">
<button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}

View file

@ -837,6 +837,8 @@ export default class MainMenu extends Module {
title: profile.name,
i18n_key: profile.i18n_key,
ephemeral: profile.ephemeral,
description: profile.description,
desc_i18n_key: profile.desc_i18n_key || profile.i18n_key && `${profile.i18n_key}.description`,
@ -883,7 +885,7 @@ export default class MainMenu extends Module {
if ( ! currentProfile ) {
for(let i=profiles.length - 1; i >= 0; i--) {
if ( profiles[i].live ) {
if ( profiles[i].live && ! profiles[i].ephemeral && profiles[i].title && ! /7tv/i.test(profiles[i].title) ) {
currentProfile = profiles[i];
break;
}

View file

@ -96,6 +96,10 @@ export default {
return this.isDefault || this.validate(this.value);
},
isReadOnly() {
return this.profile && this.profile.ephemeral
},
sourceOrder() {
return this.source ? this.source.order : Infinity
},
@ -210,6 +214,8 @@ export default {
set(value) {
// TODO: Run validation.
if ( this.isReadOnly )
return;
let process = this.item.process;
if ( process ) {
@ -228,6 +234,9 @@ export default {
},
clear() {
if ( this.isReadOnly )
return;
this.profile.delete(this.item.setting);
if ( this.item.onUIChange )

View file

@ -832,6 +832,7 @@ export default class Metadata extends Module {
const tooltip = maybe_call(def.tooltip, this, data);
if ( el.tip_content !== tooltip ) {
el.tip_content = tooltip;
el.tip.element.innerHTML = '';
setChildren(el.tip.element, tooltip);
}
}

View file

@ -918,7 +918,7 @@ export default class SettingsManager extends Module {
_saveProfiles() {
this.provider.set('profiles', this.__profiles.map(prof => prof.data));
this.provider.set('profiles', this.__profiles.filter(prof => ! prof.ephemeral).map(prof => prof.data));
for(const context of this.__contexts)
context.selectProfiles();

View file

@ -28,6 +28,11 @@ export default class SettingsProfile extends EventEmitter {
this.data = data;
this.prefix = `p:${this.id}:`;
this.enabled_key = `${this.prefix}:enabled`;
if ( this.ephemeral ) {
this._enabled = true;
this._storage = new Map;
}
}
get data() {
@ -40,6 +45,8 @@ export default class SettingsProfile extends EventEmitter {
hotkey: this.hotkey,
pause_updates: this.pause_updates,
ephemeral: this.ephemeral,
description: this.description,
desc_i18n_key: this.desc_i18n_key,
@ -70,7 +77,8 @@ export default class SettingsProfile extends EventEmitter {
save() {
this.manager.saveProfile(this.id);
if ( ! this.ephemeral )
this.manager.saveProfile(this.id);
}
@ -83,6 +91,8 @@ export default class SettingsProfile extends EventEmitter {
values: {}
};
delete out.profile.ephemeral;
for(const [k,v] of this.entries())
out.values[k] = v;
@ -99,6 +109,7 @@ export default class SettingsProfile extends EventEmitter {
return false;
// We don't want to override general settings.
delete data.profile.ephemeral;
delete data.profile.id;
delete data.profile.name;
delete data.profile.i18n_key;
@ -186,6 +197,8 @@ export default class SettingsProfile extends EventEmitter {
// ========================================================================
get toggled() {
if ( this.ephemeral )
return this._enabled;
return this.provider.get(this.enabled_key, true);
}
@ -193,7 +206,11 @@ export default class SettingsProfile extends EventEmitter {
if ( val === this.toggleState )
return;
this.provider.set(this.enabled_key, val);
if ( this.ephemeral )
this._enabled = val;
else
this.provider.set(this.enabled_key, val);
this.emit('toggled', this, val);
}
@ -226,24 +243,37 @@ export default class SettingsProfile extends EventEmitter {
// ========================================================================
get(key, default_value) {
if ( this.ephemeral )
return this._storage.get(key, default_value);
return this.provider.get(this.prefix + key, default_value);
}
set(key, value) {
this.provider.set(this.prefix + key, value);
if ( this.ephemeral )
this._storage.set(key, value);
else
this.provider.set(this.prefix + key, value);
this.emit('changed', key, value);
}
delete(key) {
this.provider.delete(this.prefix + key);
if ( this.ephemeral )
this._storage.delete(key);
else
this.provider.delete(this.prefix + key);
this.emit('changed', key, undefined, true);
}
has(key) {
if ( this.ephemeral )
return this._storage.has(key);
return this.provider.has(this.prefix + key);
}
keys() {
if ( this.ephemeral )
return Array.from(this._storage.keys());
const out = [],
p = this.prefix,
len = p.length;
@ -256,6 +286,15 @@ export default class SettingsProfile extends EventEmitter {
}
clear() {
if ( this.ephemeral ) {
const keys = this.keys();
this._storage.clear();
for(const key of keys) {
this.emit('changed', key, undefined, true);
}
return;
}
const p = this.prefix,
len = p.length;
for(const key of this.provider.keys())
@ -266,15 +305,24 @@ export default class SettingsProfile extends EventEmitter {
}
*entries() {
const p = this.prefix,
len = p.length;
if ( this.ephemeral ) {
for(const entry of this._storage.entries())
yield entry;
for(const key of this.provider.keys())
if ( key.startsWith(p) && key !== this.enabled_key )
yield [key.slice(len), this.provider.get(key)];
} else {
const p = this.prefix,
len = p.length;
for(const key of this.provider.keys())
if ( key.startsWith(p) && key !== this.enabled_key )
yield [key.slice(len), this.provider.get(key)];
}
}
get size() {
if ( this.ephemeral )
return this._storage.size;
const p = this.prefix;
let count = 0;

View file

@ -1,5 +1,8 @@
.message > div > .chat-line__message--emote {
vertical-align: baseline;
}
.message > div:not(.modified-emote) > .chat-line__message--emote {
padding-top: 5px;
}

View file

@ -685,7 +685,7 @@ export default class EmoteMenu extends Module {
return;
}
if ( t.emotes.handleClick(event) )
if ( t.emotes.handleClick(event, true) )
return;
// Check for magic.
@ -1123,6 +1123,8 @@ export default class EmoteMenu extends Module {
combineTabs: t.chat.context.get('chat.emote-menu.combine-tabs'),
showSearch: t.chat.context.get('chat.emote-menu.show-search'),
clearSearch: t.chat.context.get('chat.emote-menu.clear-search'),
hasNewEffects: false,
unlockedEffects: t.settings.provider.get('unlocked-effects', []),
tone: t.settings.provider.get('emoji-tone', null)
}
@ -1313,6 +1315,16 @@ export default class EmoteMenu extends Module {
});
}
seeEffects() {
if ( this.state.hasNewEffects ) {
t.settings.provider.set('unlocked-effects', this.state.unlockedEffects);
this.setState({
hasNewEffects: false
});
}
}
pickTone(tone) {
tone = tone || null;
t.settings.provider.set('emoji-tone', tone);
@ -1340,6 +1352,10 @@ export default class EmoteMenu extends Module {
clickTab(event) {
const tab = event.currentTarget.dataset.tab;
if ( tab === 'effect' )
this.seeEffects();
if ( this.state.combineTabs ) {
let sets;
switch(tab) {
@ -1349,7 +1365,7 @@ export default class EmoteMenu extends Module {
case 'channel':
sets = this.state.filtered_channel_sets;
break;
case 'effects':
case 'effect':
sets = this.state.filtered_effect_sets;
break;
case 'emoji':
@ -2272,7 +2288,10 @@ export default class EmoteMenu extends Module {
}
let wants_resub_info = false,
wants_plan_info = false;
wants_plan_info = false,
has_new_effects = false;
const unlocked_effects = [...t.settings.provider.get('unlocked-effects', [])];
// Finally, emotes added by FrankerFaceZ.
if ( t.chat.context.get('chat.emotes.enabled') > 1 ) {
@ -2319,9 +2338,10 @@ export default class EmoteMenu extends Module {
if ( section ) {
section.emotes.sort(sort_emotes);
if ( use_effect_tab && ! effects.includes(section) && section.has_effects )
if ( use_effect_tab && ! effects.includes(section) && section.has_effects ) {
has_new_effects = this.checkNewEffects(section.emotes, unlocked_effects) || has_new_effects;
effects.push(section);
else if ( ! all.includes(section) )
} else if ( ! all.includes(section) )
all.push(section);
}
}
@ -2337,10 +2357,11 @@ export default class EmoteMenu extends Module {
if ( section ) {
section.emotes.sort(sort_emotes);
if ( use_effect_tab && ! effects.includes(section) && section.has_effects )
if ( use_effect_tab && ! effects.includes(section) && section.has_effects ) {
has_new_effects = this.checkNewEffects(section.emotes, unlocked_effects) || has_new_effects;
effects.push(section);
else if ( ! all.includes(section) )
} else if ( ! all.includes(section) )
all.push(section);
if ( ! channel.includes(section) && maybe_call(section.force_global, this, emote_set, props.channel_data && props.channel_data.user, me) )
@ -2354,6 +2375,9 @@ export default class EmoteMenu extends Module {
state.wants_plan_info = wants_plan_info;
if ( this.props.visible ) {
if ( state.tab === 'effects' )
has_new_effects = false;
if ( wants_plan_info )
this.loadFFZPlanData();
if ( wants_resub_info )
@ -2367,11 +2391,25 @@ export default class EmoteMenu extends Module {
state.has_channel_tab = channel.length > 0;
state.has_effect_tab = effects.length > 0;
state.hasNewEffects = effects.length > 0 && has_new_effects;
state.unlockedEffects = unlocked_effects;
return this.buildEmoji(state);
}
checkNewEffects(emotes, unlocked) {
let added = false;
for(const emote of emotes) {
if ( emote && ! emote.locked && emote.id && emote.provider === 'ffz' && ! unlocked.includes(emote.id) ) {
added = true;
unlocked.push(emote.id);
}
}
return added;
}
processFFZSet(emote_set, provider, favorites, seen_favorites, grouped_sets, locked = false, state) { // eslint-disable-line class-methods-use-this
if ( ! emote_set || ! emote_set.emotes )
return null;
@ -2800,6 +2838,7 @@ export default class EmoteMenu extends Module {
data-title={t.i18n.t('emote-menu.effects', 'Emote Effects')}
onClick={this.clickTab}
>
{this.state.hasNewEffects && (<div class="ffz-new-indicator" />)}
<div class="tw-inline-flex tw-pd-x-1 tw-pd-y-05 tw-font-size-4">
<figure class="ffz-i-fx" />
</div>
@ -2930,7 +2969,7 @@ export default class EmoteMenu extends Module {
// 3. When they expire/renew
if ( ! result )
return null;
return {error: true};
const out = {
has_free_sub: result.user?.bonus_month_eligible ?? false,
@ -3022,4 +3061,6 @@ export default class EmoteMenu extends Module {
}
EmoteMenu.getData = once(EmoteMenu.getData);
EmoteMenu.getData = once(EmoteMenu.getData);
EmoteMenu.getFFZSubData = once(EmoteMenu.getFFZSubData);
EmoteMenu.getFFZSubPrices = once(EmoteMenu.getFFZSubPrices);

View file

@ -378,6 +378,15 @@ export default class ChatHook extends Module {
}
});
this.settings.add('chat.banners.charity', {
default: true,
ui: {
path: 'Chat > Appearance >> Community',
title: 'Allow the charity fundraiser progress to be displayed in chat.',
component: 'setting-check-box'
}
});
this.settings.add('chat.banners.hype-train', {
default: true,
ui: {
@ -936,6 +945,10 @@ export default class ChatHook extends Module {
this.chat.context.on('changed:chat.disable-handling', this.updateDisableHandling, this);
this.chat.context.on('changed:chat.banners.charity', () => {
this.ChatContainer.forceUpdate();
});
this.chat.context.on('changed:chat.subs.gift-banner', () => this.GiftBanner.forceUpdate(), this);
this.chat.context.on('changed:chat.effective-width', this.updateChatCSS, this);
this.settings.main_context.on('changed:chat.use-width', this.updateChatCSS, this);
@ -1313,11 +1326,30 @@ export default class ChatHook extends Module {
old_render = cls.prototype.render,
old_catch = cls.prototype.componentDidCatch;
cls.prototype.ffzRender = function() {
if ( t.chat.context.get('chat.banners.charity') )
return old_render.call(this);
const cd = this.props.campaignData;
this.props.campaignData = null;
let result;
try {
result = old_render.call(this);
} catch(err) {
this.props.campaignData = cd;
throw err;
}
this.props.campaignData = cd;
return result;
}
cls.prototype.render = function() {
try {
if ( t.CommunityStackHandler ) {
const React = t.web_munch.getModule('react'),
out = old_render.call(this),
out = this.ffzRender(),
thing = out?.props?.children?.props?.children;
if ( React && Array.isArray(thing) )
@ -1330,7 +1362,7 @@ export default class ChatHook extends Module {
// No op
}
return old_render.call(this);
return this.ffzRender();
}
// Try catching errors. With any luck, maybe we can

View file

@ -77,7 +77,7 @@ export default class Input extends Module {
ui: {
path: 'Chat > Input >> Appearance',
title: 'Display in-line previews of FrankerFaceZ emotes when entering a chat message.',
description: '**Note:** This feature is tempermental. It may not display all emotes, and emote effects and overlay emotes are not displayed correctly. Once this setting has been enabled, it cannot be reasonably disabled and will remain active until you refresh the page.',
description: '**Note:** This feature is temperamental. It may not display all emotes, and emote effects and overlay emotes are not displayed correctly. Once this setting has been enabled, it cannot be reasonably disabled and will remain active until you refresh the page.',
component: 'setting-check-box'
}
});

View file

@ -10,6 +10,9 @@
.message > div > .chat-line__message--emote {
vertical-align: baseline;
}
.message > div:not(.modified-emote) > .chat-line__message--emote {
padding-top: 5px;
}

View file

@ -723,7 +723,13 @@ export default class MenuButton extends SiteModule {
profiles.push(<div class="tw-relative tw-border-b tw-pd-y-05 tw-pd-x-1 tw-flex">
{toggle}
<div>
<h4>{ profile.i18n_key ? this.i18n.t(profile.i18n_key, profile.name, profile) : profile.name }</h4>
<h4>{ profile.i18n_key ? this.i18n.t(profile.i18n_key, profile.name, profile) : profile.name }{profile.ephemeral && (<div
class="tw-inline tw-mg-l-05 ffz-il-tooltip__container ffz--profile-row__icon ffz-i-user-secret tw-relative"
>
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-center">
{ this.i18n.t('setting.profiles.ephemeral', 'This profile is ephemeral.') }
</div>
</div>)}</h4>
{profile.description && (<div class="description">
{ desc_key ? this.i18n.t(desc_key, profile.description, profile) : profile.description }
</div>)}

View file

@ -6,6 +6,7 @@
ref="input"
v-model="color"
v-bind="$attrs"
:disabled="disabled"
type="text"
class="tw-block tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-l-1 tw-pd-r-3 tw-pd-y-05 tw-mg-y-05"
autocapitalize="off"
@ -85,6 +86,10 @@ export default {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
},
default: {
type: String,
default: '#000'
@ -171,6 +176,9 @@ export default {
},
onPick(color) {
if ( this.disabled )
return;
const old_val = this.color;
if ( color.rgba.a == 1 )

View file

@ -5,6 +5,7 @@
ref="input"
v-bind="$attrs"
class="default-dimmable tw-block tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-l-1 tw-pd-r-3 tw-pd-y-05"
:class="{'ffz--disabled': disabled}"
tabindex="0"
@click="startRecording"
@keydown="onKey"
@ -23,6 +24,7 @@
</div>
<button
:disabled="disabled"
class="ffz-button--hollow ffz-clear-key tw-absolute tw-top-0 tw-bottom-0 tw-right-0 tw-border-l tw-z-default tw-pd-x-1 ffz-il-tooltip__container"
@click="clear"
>
@ -78,7 +80,11 @@ for(const letter of 'abcdefghijklmnopqrstuvwxyz')
export default {
props: {
value: String
value: String,
disabled: {
type: Boolean,
default: false
}
},
data() {
@ -113,6 +119,9 @@ export default {
if ( e )
this.stop(e);
if ( this.disabled )
return;
if ( this.active )
return;
@ -144,6 +153,9 @@ export default {
this.stop(e);
if ( this.disabled )
return;
if ( IGNORE_KEYS.includes(k) )
return;
@ -162,6 +174,9 @@ export default {
},
clear() {
if ( this.disabled )
return;
this.$emit('input', null);
}
}

View file

@ -18,6 +18,67 @@ export const SENTRY_ID = 'https://74b46b3894114f399d51949c6d237489@sentry.franke
export const WORD_SEPARATORS = '[\\s`~<>!-#%-\\x2A,-/:;\\x3F@\\x5B-\\x5D_\\x7B}\\u00A1\\u00A7\\u00AB\\u00B6\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3\\u05F4\\u0609\\u060A\\u060C\\u060D\\u061B\\u061E\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964\\u0965\\u0970\\u0AF0\\u0DF4\\u0E4F\\u0E5A\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166D\\u166E\\u169B\\u169C\\u16EB-\\u16ED\\u1735\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944\\u1945\\u1A1E\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B5A-\\u1B60\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D\\u207E\\u208D\\u208E\\u2329\\u232A\\u2768-\\u2775\\u27C5\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC\\u29FD\\u2CF9-\\u2CFC\\u2CFE\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E3B\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE\\uA8CF\\uA8F8-\\uA8FA\\uA92E\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE\\uA9DF\\uAA5C-\\uAA5F\\uAADE\\uAADF\\uAAF0\\uAAF1\\uABEB\\uFD3E\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A\\uFF1B\\uFF1F\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65]';
export const WEIRD_EMOTE_SIZES = {
15: [21,27],
16: [22,27],
17: [20,27],
18: [20,27],
22: [19,27],
25: [25,28],
26: [20,27],
28: [39,27],
30: [29,27],
32: [21,27],
33: [25,32],
34: [21,28],
36: [36,30],
40: [21,27],
41: [19,27],
46: [24,24],
47: [24,24],
50: [18,27],
52: [32,32],
65: [40,30],
66: [20,27],
69: [41,28],
73: [21,30],
74: [24,30],
86: [36,30],
87: [24,30],
92: [23,30],
244: [24,30],
354: [20,30],
357: [28,30],
360: [22,30],
483: [20,18],
484: [20,22],
485: [27,18],
486: [21,32],
487: [15,32],
488: [29,24],
489: [20,18],
490: [20,18],
491: [20,18],
492: [20,18],
493: [20,18],
494: [20,18],
495: [20,18],
496: [20,18],
497: [20,18],
498: [20,18],
499: [20,18],
500: [20,18],
501: [20,18],
1896: [20,30],
1898: [26,28],
1899: [22,30],
1900: [33,30],
1901: [24,28],
1902: [27,29],
1904: [24,30],
1906: [24,30]
};
export const BAD_HOTKEYS = [
'f',
'space',

View file

@ -112,5 +112,6 @@ export default [
"flag",
"mange-suspicious",
"doc-text",
"fx"
"fx",
"artist"
];

View file

@ -497,6 +497,12 @@
z-index: 1
}
.ffz-alt-text {
display: inline;
font-size: 1px;
color: transparent;
}
position: relative;
z-index: 0;

View file

@ -77,6 +77,7 @@
.ffz-i-flag:before { content: '\e84b'; } /* '' */
.ffz-i-mange-suspicious:before { content: '\e84c'; } /* '' */
.ffz-i-fx:before { content: '\e84d'; } /* '' */
.ffz-i-artist:before { content: '\e84e'; } /* '' */
.ffz-i-move:before { content: '\f047'; } /* '' */
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */

File diff suppressed because one or more lines are too long

View file

@ -77,6 +77,7 @@
.ffz-i-flag { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe84b;&nbsp;'); }
.ffz-i-mange-suspicious { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe84c;&nbsp;'); }
.ffz-i-fx { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe84d;&nbsp;'); }
.ffz-i-artist { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe84e;&nbsp;'); }
.ffz-i-move { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf047;&nbsp;'); }
.ffz-i-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }
.ffz-i-twitter { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf099;&nbsp;'); }

View file

@ -88,6 +88,7 @@
.ffz-i-flag { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe84b;&nbsp;'); }
.ffz-i-mange-suspicious { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe84c;&nbsp;'); }
.ffz-i-fx { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe84d;&nbsp;'); }
.ffz-i-artist { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe84e;&nbsp;'); }
.ffz-i-move { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf047;&nbsp;'); }
.ffz-i-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }
.ffz-i-twitter { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf099;&nbsp;'); }

View file

@ -1,11 +1,11 @@
@font-face {
font-family: 'ffz-fontello';
src: url('../font/ffz-fontello.eot?59946237');
src: url('../font/ffz-fontello.eot?59946237#iefix') format('embedded-opentype'),
url('../font/ffz-fontello.woff2?59946237') format('woff2'),
url('../font/ffz-fontello.woff?59946237') format('woff'),
url('../font/ffz-fontello.ttf?59946237') format('truetype'),
url('../font/ffz-fontello.svg?59946237#ffz-fontello') format('svg');
src: url('../font/ffz-fontello.eot?56551965');
src: url('../font/ffz-fontello.eot?56551965#iefix') format('embedded-opentype'),
url('../font/ffz-fontello.woff2?56551965') format('woff2'),
url('../font/ffz-fontello.woff?56551965') format('woff'),
url('../font/ffz-fontello.ttf?56551965') format('truetype'),
url('../font/ffz-fontello.svg?56551965#ffz-fontello') format('svg');
font-weight: normal;
font-style: normal;
}
@ -15,7 +15,7 @@
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'ffz-fontello';
src: url('../font/ffz-fontello.svg?59946237#ffz-fontello') format('svg');
src: url('../font/ffz-fontello.svg?56551965#ffz-fontello') format('svg');
}
}
*/
@ -132,6 +132,7 @@
.ffz-i-flag:before { content: '\e84b'; } /* '' */
.ffz-i-mange-suspicious:before { content: '\e84c'; } /* '' */
.ffz-i-fx:before { content: '\e84d'; } /* '' */
.ffz-i-artist:before { content: '\e84e'; } /* '' */
.ffz-i-move:before { content: '\f047'; } /* '' */
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */

View file

@ -47,6 +47,24 @@
}
.ffz-new-indicator {
position: absolute;
top: 0;
right: 0;
width: 0.8rem;
height: 0.8rem;
background-color: var(--color-fill-new-item-indicator);
border-radius: var(--border-radius-rounded);
}
.emote-picker-tab-item .ffz-new-indicator {
top: 0.25rem;
right: 0.25rem;
}
.ffz-i-t-reset.loading,
.ffz-i-t-reset-clicked.loading,
.ffz-i-zreknarf.loading {

View file

@ -54,6 +54,7 @@
padding: 0;
}
&.ffz--disabled,
&:disabled {
opacity: 0.5;
pointer-events: none;