mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-07-25 12:08:30 +00:00
4.45.0
* Added: Emote cards are now at parity with vanilla Twitch emote cards when it comes to displaying Twitch emotes, including the ability to follow/subscribe to the source channel, view their other emotes, and report emotes. * Added: Emote cards for FFZ emotes now allow you to add an emote to any collection you can manage. * Added: Emote cards for other emotes now have an action to open the emote on the source service's webpage. * Fixed: Effect emotes not appearing when used incorrectly in chat. * Fixed: Rebuild the tab-completion emote array in a way more likely to cause Twitch's native input element to update when we change our emotes. * Changed: Use a higher contrast "New" pill when displaying new items in the FFZ Control Center. (Closes #1348) * Changed: Push the modular chat line rendering experiment to 100% roll-out.
This commit is contained in:
parent
53814e7024
commit
880e80388a
25 changed files with 1382 additions and 54 deletions
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "frankerfacez",
|
"name": "frankerfacez",
|
||||||
"author": "Dan Salvato LLC",
|
"author": "Dan Salvato LLC",
|
||||||
"version": "4.44.1",
|
"version": "4.45.0",
|
||||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
@ -70,8 +70,8 @@
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"graphql": "^16.0.1",
|
"graphql": "^16.0.1",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
"jszip": "^3.7.1",
|
|
||||||
"js-cookie": "^2.2.1",
|
"js-cookie": "^2.2.1",
|
||||||
|
"jszip": "^3.7.1",
|
||||||
"markdown-it": "^12.2.0",
|
"markdown-it": "^12.2.0",
|
||||||
"markdown-it-link-attributes": "^3.0.0",
|
"markdown-it-link-attributes": "^3.0.0",
|
||||||
"mnemonist": "^0.38.5",
|
"mnemonist": "^0.38.5",
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
"name": "Modular Chat Line Rendering",
|
"name": "Modular Chat Line Rendering",
|
||||||
"description": "Enable a newer, modular chat line renderer.",
|
"description": "Enable a newer, modular chat line renderer.",
|
||||||
"groups": [
|
"groups": [
|
||||||
{"value": true, "weight": 80},
|
{"value": true, "weight": 100},
|
||||||
{"value": false, "weight": 20}
|
{"value": false, "weight": 0}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"api_load": {
|
"api_load": {
|
||||||
|
|
|
@ -933,7 +933,7 @@ export default class Emotes extends Module {
|
||||||
else if ( ! value && idx !== -1 )
|
else if ( ! value && idx !== -1 )
|
||||||
favorites.splice(idx, 1);
|
favorites.splice(idx, 1);
|
||||||
else
|
else
|
||||||
return;
|
return value;
|
||||||
|
|
||||||
if ( favorites.length )
|
if ( favorites.length )
|
||||||
p.set(key, favorites);
|
p.set(key, favorites);
|
||||||
|
@ -941,6 +941,7 @@ export default class Emotes extends Module {
|
||||||
p.delete(key);
|
p.delete(key);
|
||||||
|
|
||||||
this.emit(':change-favorite', source, id, value);
|
this.emit(':change-favorite', source, id, value);
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
isFavorite(source, id) {
|
isFavorite(source, id) {
|
||||||
|
@ -1075,11 +1076,21 @@ export default class Emotes extends Module {
|
||||||
if ( favorite_only )
|
if ( favorite_only )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
let modifiers;
|
||||||
|
try {
|
||||||
|
modifiers = JSON.parse(ds.modifierInfo);
|
||||||
|
} catch(err) {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
|
||||||
const evt = new FFZEvent({
|
const evt = new FFZEvent({
|
||||||
provider,
|
provider,
|
||||||
id: ds.id,
|
id: ds.id,
|
||||||
set: ds.set,
|
set: ds.set,
|
||||||
|
code: ds.code,
|
||||||
|
variant: ds.variant,
|
||||||
name: ds.name || target.alt,
|
name: ds.name || target.alt,
|
||||||
|
modifiers,
|
||||||
source: event
|
source: event
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1680,7 +1691,7 @@ export default class Emotes extends Module {
|
||||||
animSrc2: emote.animSrc2,
|
animSrc2: emote.animSrc2,
|
||||||
animSrcSet2: emote.animSrcSet2,
|
animSrcSet2: emote.animSrcSet2,
|
||||||
masked: !! emote.mask,
|
masked: !! emote.mask,
|
||||||
hidden: (emote.modifier_flags & 1) === 1,
|
mod_hidden: (emote.modifier_flags & 1) === 1,
|
||||||
text: emote.hidden ? '???' : emote.name,
|
text: emote.hidden ? '???' : emote.name,
|
||||||
length: emote.name.length,
|
length: emote.name.length,
|
||||||
height: emote.height,
|
height: emote.height,
|
||||||
|
|
|
@ -1260,7 +1260,7 @@ export const AddonEmotes = {
|
||||||
if ( mod.effect_bg )
|
if ( mod.effect_bg )
|
||||||
as_bg = true;
|
as_bg = true;
|
||||||
|
|
||||||
if ( ! mod.hidden && mod.set !== 'info' ) {
|
if ( ! mod.mod_hidden && mod.set !== 'info' ) {
|
||||||
const factor = mod.big ? 2 : 1,
|
const factor = mod.big ? 2 : 1,
|
||||||
width = mod.width * factor,
|
width = mod.width * factor,
|
||||||
height = mod.height * factor;
|
height = mod.height * factor;
|
||||||
|
|
|
@ -1,37 +1,44 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:style="{zIndex: z}"
|
: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"
|
class="ffz-viewer-card tw-border tw-border-radius-medium tw-c-background-base tw-c-text-base tw-elevation-2 tw-flex tw-flex-column viewer-card"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@focusin="onFocus"
|
@focusin="onFocus"
|
||||||
@keyup.esc="close"
|
@keyup.esc="close"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="ffz-viewer-card__header tw-c-background-accent-alt tw-flex-grow-0 tw-flex-shrink-0 viewer-card__background tw-relative"
|
class="ffz-viewer-card__header tw-border-radius-medium 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-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-align-center tw-border-radius-medium 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">
|
<div class="tw-mg-l-05 tw-mg-y-05 tw-inline-flex viewer-card-drag-cancel">
|
||||||
<figure class="ffz-avatar" :style="imageStyle">
|
<figure v-if="! loaded" class="tw-mg-x-1 tw-font-size-2 ffz-i-zreknarf loading" />
|
||||||
|
<figure v-else class="ffz-avatar tw-flex tw-align-items-center" :style="imageStyle">
|
||||||
<img
|
<img
|
||||||
v-if="loaded && emote.src"
|
v-if="emote.src"
|
||||||
:src="emote.src"
|
:src="emote.src"
|
||||||
class="tw-block tw-image tw-image-avatar"
|
class="tw-block tw-image tw-image-avatar"
|
||||||
>
|
>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-align-left tw-flex-grow-1 tw-ellipsis tw-mg-x-1 tw-mg-y-05 viewer-card__display-name">
|
<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">
|
<h4 class="tw-inline tw-ellipsis" :title="emote ? emote.name : raw_emote.name">
|
||||||
{{ emote ? emote.name : raw_emote.name }}
|
{{ emote ? emote.name : raw_emote.name }}
|
||||||
</h4>
|
</h4>
|
||||||
|
<P
|
||||||
|
v-if="! loaded"
|
||||||
|
class="tw-c-text-alt-2 tw-font-size-6"
|
||||||
|
>
|
||||||
|
{{ t('emote-card.loading', 'Loading...') }}
|
||||||
|
</P>
|
||||||
<p
|
<p
|
||||||
v-if="loaded && emote.source"
|
v-if="loaded && emote.source"
|
||||||
class="tw-c-text-alt-2 tw-font-size-6"
|
class="tw-c-text-alt-2 tw-font-size-6 tw-ellipsis"
|
||||||
:title="emote.source_i18n ? t(emote.source_i18n, emote.source) : emote.source"
|
:title="emote.source_i18n ? t(emote.source_i18n, emote.source) : emote.source"
|
||||||
>
|
>
|
||||||
{{ emote.source_i18n ? t(emote.source_i18n, emote.source) : emote.source }}
|
{{ emote.source_i18n ? t(emote.source_i18n, emote.source) : emote.source }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="loaded && emote.owner" class="tw-c-text-alt-2 tw-font-size-6">
|
<p v-if="loaded && emote.owner" class="tw-c-text-alt-2 tw-font-size-6 tw-ellipsis">
|
||||||
<t-list
|
<t-list
|
||||||
phrase="emote-card.owner"
|
phrase="emote-card.owner"
|
||||||
default="Owner: {owner}"
|
default="Owner: {owner}"
|
||||||
|
@ -47,7 +54,7 @@
|
||||||
</template>
|
</template>
|
||||||
</t-list>
|
</t-list>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="loaded && emote.artist" class="tw-c-text-alt-2 tw-font-size-6">
|
<p v-if="loaded && emote.artist" class="tw-c-text-alt-2 tw-font-size-6 tw-ellipsis">
|
||||||
<t-list
|
<t-list
|
||||||
phrase="emote-card.artist"
|
phrase="emote-card.artist"
|
||||||
default="Artist: {artist}"
|
default="Artist: {artist}"
|
||||||
|
@ -65,6 +72,20 @@
|
||||||
</t-list>
|
</t-list>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="canFavorite"
|
||||||
|
:data-title="favoriteLabel"
|
||||||
|
:aria-label="favoriteLabel"
|
||||||
|
class="viewer-card-drag-cancel tw-align-self-start 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="toggleFavorite"
|
||||||
|
>
|
||||||
|
<span class="tw-button-icon__icon">
|
||||||
|
<figure :class="{
|
||||||
|
'ffz-i-star': isFavorite,
|
||||||
|
'ffz-i-star-empty': ! isFavorite
|
||||||
|
}" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<div class="tw-flex tw-flex-column tw-align-self-start">
|
<div class="tw-flex tw-flex-column tw-align-self-start">
|
||||||
<button
|
<button
|
||||||
:data-title="t('emote-card.close', 'Close')"
|
:data-title="t('emote-card.close', 'Close')"
|
||||||
|
@ -96,9 +117,10 @@
|
||||||
color="background-alt-2"
|
color="background-alt-2"
|
||||||
dir="down-right"
|
dir="down-right"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
class="tw-border-radius-medium"
|
||||||
>
|
>
|
||||||
<simplebar classes="ffz-mh-30">
|
<simplebar classes="ffz-mh-30">
|
||||||
<div class="tw-pd-y-1">
|
<div class="tw-pd-y-05">
|
||||||
<template v-for="(entry, idx) in moreActions">
|
<template v-for="(entry, idx) in moreActions">
|
||||||
<div
|
<div
|
||||||
v-if="entry.divider"
|
v-if="entry.divider"
|
||||||
|
@ -142,24 +164,41 @@
|
||||||
:getFFZ="getFFZ"
|
:getFFZ="getFFZ"
|
||||||
@close="close"
|
@close="close"
|
||||||
/>
|
/>
|
||||||
|
<component
|
||||||
|
v-if="! reporting && loaded && hasBody"
|
||||||
|
:is="bodyComponent"
|
||||||
|
:emote="emote"
|
||||||
|
:getFFZ="getFFZ"
|
||||||
|
@close="close"
|
||||||
|
/>
|
||||||
|
<Modifiers
|
||||||
|
v-if="! reporting && raw_modifiers && raw_modifiers.length"
|
||||||
|
:raw_modifiers="raw_modifiers"
|
||||||
|
:getFFZ="getFFZ"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
|
import ManageFFZ from './manage-ffz.vue';
|
||||||
|
import Modifiers from './modifiers.vue';
|
||||||
import ReportForm from './report-form.vue';
|
import ReportForm from './report-form.vue';
|
||||||
|
import TwitchBody from './twitch-body.vue';
|
||||||
|
|
||||||
import displace from 'displacejs';
|
import displace from 'displacejs';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
Modifiers,
|
||||||
ReportForm
|
ReportForm
|
||||||
},
|
},
|
||||||
|
|
||||||
props: [
|
props: [
|
||||||
'raw_emote', 'data',
|
'raw_emote', 'data',
|
||||||
'pos_x', 'pos_y',
|
'pos_x', 'pos_y',
|
||||||
'getZ', 'getFFZ'
|
'getZ', 'getFFZ', 'reportTwitchEmote',
|
||||||
|
'raw_modifiers'
|
||||||
],
|
],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
@ -167,6 +206,7 @@ export default {
|
||||||
z: this.getZ(),
|
z: this.getZ(),
|
||||||
|
|
||||||
moreOpen: false,
|
moreOpen: false,
|
||||||
|
isFavorite: false,
|
||||||
|
|
||||||
reporting: false,
|
reporting: false,
|
||||||
|
|
||||||
|
@ -179,6 +219,30 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
|
favoriteLabel() {
|
||||||
|
return this.t('emote-card.fav', 'Favorite This Emote');
|
||||||
|
},
|
||||||
|
|
||||||
|
hasBody() {
|
||||||
|
return this.bodyComponent != null
|
||||||
|
},
|
||||||
|
|
||||||
|
bodyComponent() {
|
||||||
|
const body = this.emote?.body;
|
||||||
|
|
||||||
|
if ( body === 'twitch' )
|
||||||
|
return TwitchBody;
|
||||||
|
|
||||||
|
if ( body === 'manage-ffz' )
|
||||||
|
return ManageFFZ;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
canFavorite() {
|
||||||
|
return this.loaded && this.emote.fav_source;
|
||||||
|
},
|
||||||
|
|
||||||
moreActions() {
|
moreActions() {
|
||||||
if ( ! this.loaded || ! this.emote.more )
|
if ( ! this.loaded || ! this.emote.more )
|
||||||
return null;
|
return null;
|
||||||
|
@ -209,6 +273,9 @@ export default {
|
||||||
this.ffzEmit(':load', this);
|
this.ffzEmit(':load', this);
|
||||||
this.emote = data;
|
this.emote = data;
|
||||||
|
|
||||||
|
this.updateIsFavorite();
|
||||||
|
this.$nextTick(() => this.handleResize());
|
||||||
|
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Error loading emote card data', err);
|
console.error('Error loading emote card data', err);
|
||||||
this.errored = true;
|
this.errored = true;
|
||||||
|
@ -231,6 +298,24 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
updateIsFavorite() {
|
||||||
|
if ( ! this.emote || ! this.emote.fav_source )
|
||||||
|
this.isFavorite = false;
|
||||||
|
else {
|
||||||
|
const emotes = this.getFFZ().resolve('chat.emotes');
|
||||||
|
this.isFavorite = emotes.isFavorite(this.emote.fav_source, this.emote.fav_id ?? this.emote.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleFavorite() {
|
||||||
|
if ( ! this.emote || ! this.emote.fav_source )
|
||||||
|
return;
|
||||||
|
|
||||||
|
const emotes = this.getFFZ().resolve('chat.emotes');
|
||||||
|
this.isFavorite = emotes.toggleFavorite(this.emote.fav_source, this.emote.fav_id ?? this.emote.id);
|
||||||
|
this.cleanTips();
|
||||||
|
},
|
||||||
|
|
||||||
toggleMore() {
|
toggleMore() {
|
||||||
this.moreOpen = ! this.moreOpen;
|
this.moreOpen = ! this.moreOpen;
|
||||||
},
|
},
|
||||||
|
@ -249,7 +334,14 @@ export default {
|
||||||
|
|
||||||
if ( entry.type === 'report-ffz' )
|
if ( entry.type === 'report-ffz' )
|
||||||
this.reporting = true;
|
this.reporting = true;
|
||||||
|
this.$nextTick(() => this.handleResize());
|
||||||
|
|
||||||
|
if ( entry.type === 'report-twitch' ) {
|
||||||
|
if ( this.reportTwitchEmote(this.emote.id, this.emote.channel_id) )
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
constrain() {
|
constrain() {
|
||||||
|
|
197
src/modules/emote_card/components/manage-ffz-collection.vue
Normal file
197
src/modules/emote_card/components/manage-ffz-collection.vue
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="root"
|
||||||
|
class="tw-mg-05 tw-border tw-border-radius-medium tw-pd-05 ffz--cursor"
|
||||||
|
role="checkbox"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-checked="isInCollection"
|
||||||
|
:class="entryClasses"
|
||||||
|
@click="toggle"
|
||||||
|
@keypress="onPress($event)"
|
||||||
|
>
|
||||||
|
<div class="panel-heading tw-flex tw-align-items-center">
|
||||||
|
<span
|
||||||
|
:data-title="iconTip"
|
||||||
|
class="ffz-tooltip tw-mg-r-1"
|
||||||
|
:class="iconClasses"
|
||||||
|
/>
|
||||||
|
<figure v-if="image" class="ffz-avatar ffz-avatar--size-20 tw-mg-r-1">
|
||||||
|
<img
|
||||||
|
class="tw-block tw-image tw-image-avatar"
|
||||||
|
:src="image"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
<div class="tw-flex-grow-1">
|
||||||
|
{{ collection.title }}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="ffz-pill"
|
||||||
|
:class="{
|
||||||
|
'ffz-pill--alert': collection.count > collection.limit,
|
||||||
|
'ffz-pill--warn': collection.count === collection.limit,
|
||||||
|
//'tw-pill--': collection.count < collection.limit
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ t('collection.count', '{count,number} of {limit,number}', collection) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
props: [
|
||||||
|
'initial',
|
||||||
|
'collection',
|
||||||
|
'emote',
|
||||||
|
'getFFZ'
|
||||||
|
],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isInCollection: this.initial,
|
||||||
|
shaking: false,
|
||||||
|
loading: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
image() {
|
||||||
|
if ( this.collection.icon )
|
||||||
|
return this.collection.icon;
|
||||||
|
|
||||||
|
const owner = this.collection.owner;
|
||||||
|
if ( owner.provider && owner.provider_id )
|
||||||
|
return `https://cdn.frankerfacez.com/avatar/${owner.provider}/${owner.provider_id}`;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
iconTip() {
|
||||||
|
if ( this.isInCollection )
|
||||||
|
return this.t('emote-card.in-collection', 'This emote is in this collection.');
|
||||||
|
|
||||||
|
return this.t('emote-card.not-in-collection', 'This emote is not in this collection.');
|
||||||
|
},
|
||||||
|
|
||||||
|
iconClasses() {
|
||||||
|
if ( this.loading )
|
||||||
|
return 'ffz-i-arrows-cw ffz--rotate';
|
||||||
|
|
||||||
|
if ( this.isInCollection )
|
||||||
|
return 'ffz-i-ok';
|
||||||
|
|
||||||
|
return 'ffz-i-minus';
|
||||||
|
},
|
||||||
|
|
||||||
|
entryClasses() {
|
||||||
|
if ( this.shaking )
|
||||||
|
return 'tw-c-background-alt ffz--shaking';
|
||||||
|
|
||||||
|
if ( this.loading )
|
||||||
|
return 'tw-c-background-alt-2';
|
||||||
|
|
||||||
|
if ( this.isInCollection )
|
||||||
|
return 'tw-c-background-accent';
|
||||||
|
|
||||||
|
return 'tw-c-background-alt';
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.onAnimationEnd = this.onAnimationEnd.bind(this);
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.$refs.root.addEventListener('animationend', this.onAnimationEnd);
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeUnmount() {
|
||||||
|
this.$refs.root.removeEventListener('animationend', this.onAnimationEnd);
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onAnimationEnd() {
|
||||||
|
this.shaking = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
errorShake() {
|
||||||
|
this.shaking = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
onPress(evt) {
|
||||||
|
if ( evt.keyCode !== 32 )
|
||||||
|
return;
|
||||||
|
|
||||||
|
evt.preventDefault();
|
||||||
|
this.toggle();
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggle() {
|
||||||
|
if ( this.loading )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.toggleInternal();
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
this.errorShake();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleInternal() {
|
||||||
|
const server = this.getFFZ().resolve('staging').api,
|
||||||
|
url = `${server}/v2/collection/${this.collection.id}/emote/${this.emote.id}`;
|
||||||
|
|
||||||
|
const socket = this.getFFZ().resolve('socket'),
|
||||||
|
token = socket && await socket.getBareAPIToken();
|
||||||
|
|
||||||
|
if ( ! token )
|
||||||
|
throw new Error('Unable to get API token. Are you logged in?');
|
||||||
|
|
||||||
|
if ( ! this.isInCollection ) {
|
||||||
|
if ( this.collection.count >= this.collection.limit )
|
||||||
|
throw new Error('collection at limit');
|
||||||
|
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}).then(r => r.ok ? r.json() : null);
|
||||||
|
|
||||||
|
this.isInCollection = true;
|
||||||
|
|
||||||
|
if ( resp?.collection )
|
||||||
|
this.collection.count = resp.collection.count;
|
||||||
|
else
|
||||||
|
this.collection.count++;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}).then(r => r.ok ? r.json() : null);
|
||||||
|
|
||||||
|
this.isInCollection = false;
|
||||||
|
|
||||||
|
if ( resp?.collection )
|
||||||
|
this.collection.count = resp.collection.count;
|
||||||
|
else
|
||||||
|
this.collection.count--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
124
src/modules/emote_card/components/manage-ffz.vue
Normal file
124
src/modules/emote_card/components/manage-ffz.vue
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="ffz-emote-card__management"
|
||||||
|
:class="{'tw-pd-b-05': expanded}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tw-flex tw-align-items-center tw-c-background-alt-2 tw-pd-y-05 tw-pd-x-1 ffz--cursor"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<div class="tw-flex-grow-1">
|
||||||
|
<h4>{{ t('emote-card.manage', 'Manage My Collections') }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure
|
||||||
|
:class="{
|
||||||
|
'ffz-i-down-dir': expanded,
|
||||||
|
'ffz-i-left-dir': ! expanded
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<simplebar
|
||||||
|
v-if="expanded"
|
||||||
|
classes="ffz-mh-30"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="tw-align-center tw-pd-1">
|
||||||
|
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" 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.error', 'There was an error loading data.') }}
|
||||||
|
</div>
|
||||||
|
<CollectionEntry
|
||||||
|
v-else
|
||||||
|
v-for="collection in collections"
|
||||||
|
:key="collection.id"
|
||||||
|
:collection="collection"
|
||||||
|
:emote="emote"
|
||||||
|
:getFFZ="getFFZ"
|
||||||
|
:initial="presence.includes(collection.id)"
|
||||||
|
/>
|
||||||
|
</simplebar>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import CollectionEntry from './manage-ffz-collection.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
components: {
|
||||||
|
CollectionEntry
|
||||||
|
},
|
||||||
|
|
||||||
|
props: [
|
||||||
|
'emote',
|
||||||
|
'getFFZ'
|
||||||
|
],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
expanded: false,
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
presence: null,
|
||||||
|
collections: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
toggle() {
|
||||||
|
this.expanded = ! this.expanded;
|
||||||
|
if ( this.expanded && ! this.collections )
|
||||||
|
this.loadCollections();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadCollections() {
|
||||||
|
if ( this.loading )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
this._loadCollections()
|
||||||
|
.then(() => {
|
||||||
|
this.loading = false;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
this.error = true;
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadCollections() {
|
||||||
|
const socket = this.getFFZ().resolve('socket'),
|
||||||
|
token = socket && await socket.getBareAPIToken();
|
||||||
|
|
||||||
|
if ( ! token )
|
||||||
|
throw new Error('Unable to get API token. Are you logged in?');
|
||||||
|
|
||||||
|
const server = this.getFFZ().resolve('staging').api,
|
||||||
|
results = await fetch(`${server}/v2/emote/${this.emote.id}/collections/editable?include=collection`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}).then(r => r.ok ? r.json() : null);
|
||||||
|
|
||||||
|
this.presence = results?.emote?.collections ?? [];
|
||||||
|
this.collections = results?.collections;
|
||||||
|
|
||||||
|
if ( this.collections != null && Object.keys(this.collections).length === 0 )
|
||||||
|
throw new Error('No collections returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
185
src/modules/emote_card/components/modifiers.vue
Normal file
185
src/modules/emote_card/components/modifiers.vue
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="ffz-emote-card__modifiers"
|
||||||
|
:class="{'tw-pd-b-05': expanded}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="tw-flex tw-align-items-center tw-c-background-alt-2 tw-pd-y-05 tw-pd-x-1 ffz--cursor"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<div class="tw-flex-grow-1">
|
||||||
|
<h4>{{ t('emote-card.modifiers', 'Modifiers') }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figure
|
||||||
|
:class="{
|
||||||
|
'ffz-i-down-dir': expanded,
|
||||||
|
'ffz-i-left-dir': ! expanded
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="expanded"
|
||||||
|
v-for="(mod, idx) in modifiers"
|
||||||
|
:key="idx"
|
||||||
|
class="tw-pd-05 tw-flex tw-align-items-center tw-border-t"
|
||||||
|
>
|
||||||
|
<div class="tw-mg-l-05 tw-inline-flex">
|
||||||
|
<figure
|
||||||
|
v-if="mod.icon"
|
||||||
|
class="ffz-avatar ffz-avatar--50"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="mod.icon"
|
||||||
|
class="tw-block tw-image tw-image-avatar"
|
||||||
|
>
|
||||||
|
</figure>
|
||||||
|
<figure
|
||||||
|
v-else
|
||||||
|
class="ffz-avatar"
|
||||||
|
:style="mod.imageStyle"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="mod.src"
|
||||||
|
:src="mod.src"
|
||||||
|
:srcset="mod.srcSet"
|
||||||
|
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">
|
||||||
|
<h4 class="tw-inline" :title="mod.name">{{ mod.name }}</h4>
|
||||||
|
<p
|
||||||
|
v-if="mod.source"
|
||||||
|
class="tw-c-text-alt-2 tw-font-size-6"
|
||||||
|
:title="mod.source_i18n ? t(mod.source_i18n, mod.source) : mod.source"
|
||||||
|
>
|
||||||
|
{{ mod.source_i18n ? t(mod.source_i18n, mod.source) : mod.source }}
|
||||||
|
</p>
|
||||||
|
<p v-if="mod.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="mod.ownerLink"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
:href="mod.ownerLink"
|
||||||
|
>{{ mod.owner }}</a>
|
||||||
|
<span v-else>{{ mod.owner }}</span>
|
||||||
|
</template>
|
||||||
|
</t-list>
|
||||||
|
</p>
|
||||||
|
<p v-if="mod.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="mod.artistLink"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
:href="mod.artistLink"
|
||||||
|
class="ffz-i-artist"
|
||||||
|
>{{ mod.artist }}</a>
|
||||||
|
<span v-else>{{ mod.artist }}</span>
|
||||||
|
</template>
|
||||||
|
</t-list>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
props: [
|
||||||
|
'raw_modifiers',
|
||||||
|
'getFFZ'
|
||||||
|
],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
const ffz = this.getFFZ(),
|
||||||
|
settings = ffz.resolve('settings'),
|
||||||
|
provider = settings.provider;
|
||||||
|
|
||||||
|
return {
|
||||||
|
expanded: provider.get('emote-card.expand-mods', true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
modifiers() {
|
||||||
|
const ffz = this.getFFZ(),
|
||||||
|
emotes = ffz.resolve('chat.emotes');
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for(const [set_id, emote_id] of this.raw_modifiers) {
|
||||||
|
if ( set_id === 'info' ) {
|
||||||
|
out.push({
|
||||||
|
type: 'info',
|
||||||
|
icon: emote_id?.icon,
|
||||||
|
name: emote_id?.label
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emote_set = emotes.emote_sets[set_id],
|
||||||
|
emote = emote_set?.emotes?.[emote_id];
|
||||||
|
|
||||||
|
if ( emote ) {
|
||||||
|
const is_effect = emote.modifier_flags != 0;
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
type: 'emote',
|
||||||
|
id: emote.id,
|
||||||
|
src: emote.animSrc ?? emote.src,
|
||||||
|
srcSet: emote.animSrcSet ?? emote.srcSet,
|
||||||
|
width: emote.width,
|
||||||
|
height: emote.height,
|
||||||
|
name: emote.name,
|
||||||
|
imageStyle: {
|
||||||
|
width: `${Math.min(112, (emote.width ?? 28) * 1)}px`,
|
||||||
|
height: `${(emote.height ?? 28) * 1}px`
|
||||||
|
},
|
||||||
|
source: emote_set.source_line || (`${emote_set.source || 'FFZ'} ${emote_set.title || 'Global Emotes'}`),
|
||||||
|
owner: emote.owner && ! is_effect
|
||||||
|
? (emote.owner.display_name || emote.owner.name)
|
||||||
|
: null,
|
||||||
|
ownerLink: emote.owner && ! is_effect && ! emote_set.source
|
||||||
|
? `https://www.frankerfacez.com/${emote.owner.name}`
|
||||||
|
: null,
|
||||||
|
artist: emote.artist
|
||||||
|
? (emote.artist.display_name || emote.artist.name)
|
||||||
|
: null,
|
||||||
|
artistLink: emote.artist && ! emote_set.source
|
||||||
|
? `https://www.frankerfacez.com/${emote.artist.name}`
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
toggle() {
|
||||||
|
const ffz = this.getFFZ(),
|
||||||
|
settings = ffz.resolve('settings'),
|
||||||
|
provider = settings.provider;
|
||||||
|
|
||||||
|
this.expanded = ! this.expanded
|
||||||
|
provider.set('emote-card.expand-mods', this.expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
194
src/modules/emote_card/components/twitch-body.vue
Normal file
194
src/modules/emote_card/components/twitch-body.vue
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
<template>
|
||||||
|
<section class="ffz-emote-card__twitch tw-pd-1">
|
||||||
|
<section
|
||||||
|
v-if="emote.channel_id"
|
||||||
|
class="tw-mg-b-05 tw-flex tw-align-items-center"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="`https://www.twitch.tv/${emote.channel_login}`"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
class="tw-semibold tw-font-size-4 ffz-i-camera"
|
||||||
|
>
|
||||||
|
{{ emote.channel_title }}
|
||||||
|
</a>
|
||||||
|
<div v-if="emote.channel_live" class="tw-mg-l-1">
|
||||||
|
<figure class="ffz-emote-card__live-indicator tw-mg-r-05" />
|
||||||
|
{{ t('emote-card.live', 'LIVE') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
<template v-if="isSubscriptionEmote">
|
||||||
|
<p v-if="emote.unlocked">
|
||||||
|
{{ t('emote-card.sub.unlocked', 'You have unlocked this emote by subscribing to {user}\'s channel at Tier {tier}.', {
|
||||||
|
tier: emote.unlock_tier,
|
||||||
|
user: emote.channel_title
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="emote.unlock_tier <= 1">
|
||||||
|
{{ t('emote-card.sub.upsell', "Subscribe to {user}'s channel to use {emote} along with {emotes, plural, one {# more emote} other {# more of their emotes} }, including:", {
|
||||||
|
emote: emote.name,
|
||||||
|
emotes: unlockCount,
|
||||||
|
user: emote.channel_title
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
{{ t('emote-card.sub.upsell-tier', "Subscribe to {user}'s channel at Tier {tier} to use {emote} along with {emotes, plural, one {# more emote} other {# more of their emotes} }, including:", {
|
||||||
|
emote: emote.name,
|
||||||
|
tier: emote.unlock_tier,
|
||||||
|
emotes: unlockCount,
|
||||||
|
user: emote.channel_title
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isBitsEmote">
|
||||||
|
<p v-if="emote.unlocked">
|
||||||
|
{{ t('emote-card.bits.unlocked', 'You have unlocked this emote by using {count, plural, one {# bit} other {# bits} } in {user}\'s channel.', {
|
||||||
|
count: emote.bits_amount,
|
||||||
|
user: emote.channel_title
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
{{ t('emote-card.bits.upsell', "Use {count, plural, one {# more bit} other {# more bits} } in {user}'s channel to permanently unlock this emote reward.", {
|
||||||
|
count: emote.bits_remain,
|
||||||
|
user: emote.channel_title
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isFollowEmote">
|
||||||
|
<p v-if="emote.unlocked">
|
||||||
|
{{ t('emote-card.follow.unlocked', 'You have unlocked this emote by following {user}\'s channel.', {
|
||||||
|
user: emote.channel_title
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
{{ t('emote-card.follow.upsell', "Follow {user}'s channel to use {emotes, plural, one {their emote} other {# of their emotes} }, including:", {
|
||||||
|
emotes: unlockCount,
|
||||||
|
user: emote.channel_title
|
||||||
|
}) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<div v-if="extras.length" class="ffz-emote-card__emote-list tw-mg-t-05">
|
||||||
|
<div
|
||||||
|
v-for="extra in extras"
|
||||||
|
:key="extra.id"
|
||||||
|
:data-title="extra.name"
|
||||||
|
class="ffz-tooltip"
|
||||||
|
>
|
||||||
|
<img :src="extra.src" :srcset="extra.srcSet" :alt="extra.name" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="emote.channel_id" class="tw-mg-t-1 tw-flex">
|
||||||
|
<follow-button
|
||||||
|
:channel="emote.channel_id"
|
||||||
|
:initial="emote.channel_followed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canSubscribe"
|
||||||
|
class="tw-button tw-mg-l-1"
|
||||||
|
@click="subscribe"
|
||||||
|
>
|
||||||
|
<span class="tw-button__icon tw-button__icon--left">
|
||||||
|
<figure class="ffz-i-star" />
|
||||||
|
</span>
|
||||||
|
<span class="tw-button__text">
|
||||||
|
{{ t('emote-card.sub-button', 'Subscribe') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="emote.product_price" class="ffz-button__sub-price">
|
||||||
|
{{ emote.product_price }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
props: [
|
||||||
|
'emote',
|
||||||
|
'getFFZ'
|
||||||
|
],
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
unlockCount() {
|
||||||
|
if ( Array.isArray(this.emote.extra_emotes) )
|
||||||
|
return this.emote.extra_emotes.length;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
extras() {
|
||||||
|
if ( Array.isArray(this.emote.extra_emotes) )
|
||||||
|
return this.emote.extra_emotes.slice(0, 8);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
isSubscriptionEmote() {
|
||||||
|
return this.emote.unlock_mode === 'subscribe';
|
||||||
|
},
|
||||||
|
|
||||||
|
isBitsEmote() {
|
||||||
|
return this.emote.unlock_mode === 'bits';
|
||||||
|
},
|
||||||
|
|
||||||
|
isFollowEmote() {
|
||||||
|
return this.emote.unlock_mode === 'follow';
|
||||||
|
},
|
||||||
|
|
||||||
|
canSubscribe() {
|
||||||
|
if ( ! this.isSubscriptionEmote || this.emote.unlocked )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Only show the sub button if we have a target product.
|
||||||
|
if ( ! this.emote.channel_product )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const settings = this.getFFZ().resolve('settings'),
|
||||||
|
current_channel = settings.get('context.channelID');
|
||||||
|
|
||||||
|
// Only show the subscribe button for the current channel.
|
||||||
|
if ( current_channel !== this.emote.channel_id )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Finally, make sure we can find the right UI elements.
|
||||||
|
const store = this.getFFZ().resolve('site')?.store,
|
||||||
|
web_munch = this.getFFZ().resolve('site.web_munch'),
|
||||||
|
sub_form = web_munch.getModule('sub-form');
|
||||||
|
|
||||||
|
if ( ! store?.dispatch || ! sub_form )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
subscribe() {
|
||||||
|
if ( ! this.canSubscribe )
|
||||||
|
return;
|
||||||
|
|
||||||
|
const store = this.getFFZ().resolve('site')?.store,
|
||||||
|
web_munch = this.getFFZ().resolve('site.web_munch'),
|
||||||
|
sub_form = web_munch.getModule('sub-form');
|
||||||
|
|
||||||
|
if ( ! store?.dispatch || ! sub_form )
|
||||||
|
return;
|
||||||
|
|
||||||
|
sub_form({
|
||||||
|
productName: this.emote.channel_product,
|
||||||
|
trackingContext: {
|
||||||
|
source: 'emote_card'
|
||||||
|
}
|
||||||
|
})(store.dispatch);
|
||||||
|
|
||||||
|
this.$emit('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
|
@ -4,8 +4,8 @@
|
||||||
// Emote Cards
|
// Emote Cards
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {createElement, sanitize} from 'utilities/dom';
|
import {createElement} from 'utilities/dom';
|
||||||
import {has, maybe_call, deep_copy, getTwitchEmoteURL} from 'utilities/object';
|
import {deep_copy, getTwitchEmoteURL} from 'utilities/object';
|
||||||
import { EmoteTypes } from 'utilities/constants';
|
import { EmoteTypes } from 'utilities/constants';
|
||||||
|
|
||||||
import GET_EMOTE from './twitch_data.gql';
|
import GET_EMOTE from './twitch_data.gql';
|
||||||
|
@ -34,6 +34,17 @@ function getEmoteTypeFromTwitchType(type) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function tierToNumber(tier) {
|
||||||
|
if ( tier === '1000' || tier === 'prime' )
|
||||||
|
return 1;
|
||||||
|
if ( tier === '2000' )
|
||||||
|
return 2;
|
||||||
|
if ( tier === '3000' )
|
||||||
|
return 3;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default class EmoteCard extends Module {
|
export default class EmoteCard extends Module {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
@ -43,6 +54,7 @@ export default class EmoteCard extends Module {
|
||||||
this.inject('i18n');
|
this.inject('i18n');
|
||||||
this.inject('chat');
|
this.inject('chat');
|
||||||
this.inject('chat.emotes');
|
this.inject('chat.emotes');
|
||||||
|
this.inject('chat.emoji');
|
||||||
this.inject('site');
|
this.inject('site');
|
||||||
this.inject('site.apollo');
|
this.inject('site.apollo');
|
||||||
this.inject('site.twitch_data');
|
this.inject('site.twitch_data');
|
||||||
|
@ -68,9 +80,12 @@ export default class EmoteCard extends Module {
|
||||||
|
|
||||||
this.openCard({
|
this.openCard({
|
||||||
provider: evt.provider,
|
provider: evt.provider,
|
||||||
|
code: evt.code,
|
||||||
|
variant: evt.variant,
|
||||||
|
name: evt.name,
|
||||||
set: evt.set,
|
set: evt.set,
|
||||||
id: evt.id
|
id: evt.id ?? `${evt.code}::${evt.variant}`
|
||||||
}, evt.source);
|
}, evt.modifiers, evt.source);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,12 +94,66 @@ export default class EmoteCard extends Module {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await this.vue.enable();
|
await this.vue.enable();
|
||||||
const card_component = await import('./components/card.vue');
|
const card_component = await import(/* webpackChunkName: 'emote-cards' */ './components/card.vue');
|
||||||
this.vue.component('emote-card', card_component.default);
|
this.vue.component('emote-card', card_component.default);
|
||||||
|
|
||||||
this._vue_loaded = true;
|
this._vue_loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
canReportTwitch() {
|
||||||
|
const site = this.resolve('site'),
|
||||||
|
core = site.getCore(),
|
||||||
|
user = site.getUser(),
|
||||||
|
web_munch = this.resolve('site.web_munch');
|
||||||
|
|
||||||
|
let report_form;
|
||||||
|
try {
|
||||||
|
report_form = web_munch.getModule('user-report');
|
||||||
|
} catch(err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !! report_form && !! user?.id && core?.store?.dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
reportTwitchEmote(id, channel) {
|
||||||
|
const site = this.resolve('site'),
|
||||||
|
core = site.getCore(),
|
||||||
|
user = site.getUser(),
|
||||||
|
web_munch = this.resolve('site.web_munch');
|
||||||
|
|
||||||
|
let report_form;
|
||||||
|
try {
|
||||||
|
report_form = web_munch.getModule('user-report');
|
||||||
|
} catch(err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! user?.id || ! core?.store?.dispatch )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
core.store.dispatch({
|
||||||
|
type: 'core.modal.MODAL_SHOWN',
|
||||||
|
modalComponent: report_form,
|
||||||
|
modalProps: {
|
||||||
|
reportContext: {
|
||||||
|
contentID: String(id),
|
||||||
|
contentMetadata: {
|
||||||
|
channelID: String(user.id)
|
||||||
|
},
|
||||||
|
contentType: 'EMOTE_REPORT',
|
||||||
|
targetUserID: String(channel),
|
||||||
|
trackingContext: 'emote_card'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async loadData(emote) {
|
async loadData(emote) {
|
||||||
if ( emote.provider === 'twitch' ) {
|
if ( emote.provider === 'twitch' ) {
|
||||||
const apollo = this.resolve('site.apollo');
|
const apollo = this.resolve('site.apollo');
|
||||||
|
@ -107,6 +176,8 @@ export default class EmoteCard extends Module {
|
||||||
const srcSet = `${src} 1x, ${getTwitchEmoteURL(data.id, 4, true, true)} 2x`;
|
const srcSet = `${src} 1x, ${getTwitchEmoteURL(data.id, 4, true, true)} 2x`;
|
||||||
|
|
||||||
let source;
|
let source;
|
||||||
|
let body;
|
||||||
|
let tier;
|
||||||
|
|
||||||
//console.log("loaded data", data);
|
//console.log("loaded data", data);
|
||||||
|
|
||||||
|
@ -114,37 +185,30 @@ export default class EmoteCard extends Module {
|
||||||
|
|
||||||
if ( type === EmoteTypes.Subscription ) {
|
if ( type === EmoteTypes.Subscription ) {
|
||||||
const products = data.owner?.subscriptionProducts;
|
const products = data.owner?.subscriptionProducts;
|
||||||
let tier;
|
|
||||||
|
|
||||||
if ( Array.isArray(products) ) {
|
if ( Array.isArray(products) ) {
|
||||||
for(const product of products) {
|
for(const product of products) {
|
||||||
if ( product.emotes.some(em => em.id === data.id) ) {
|
if ( product.emotes.some(em => em.id === data.id) ) {
|
||||||
tier = product.tier;
|
tier = tierToNumber(product.tier);
|
||||||
break;
|
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})', {
|
source = this.i18n.t('emote-card.sub', 'Tier {tier} Sub Emote ({source})', {
|
||||||
tier: tier,
|
tier: tier,
|
||||||
source: data.owner.displayName || data.owner.login
|
source: data.owner.displayName || data.owner.login
|
||||||
});
|
});
|
||||||
|
|
||||||
} else if ( type === EmoteTypes.Follower )
|
body = 'twitch';
|
||||||
|
|
||||||
|
} else if ( type === EmoteTypes.Follower ) {
|
||||||
source = this.i18n.t('emote.follower', 'Follower Emote ({source})', {
|
source = this.i18n.t('emote.follower', 'Follower Emote ({source})', {
|
||||||
source: data.owner.displayName || data.owner.login
|
source: data.owner.displayName || data.owner.login
|
||||||
});
|
});
|
||||||
|
body = 'twitch';
|
||||||
|
|
||||||
else if ( type === EmoteTypes.Global )
|
} else if ( type === EmoteTypes.Global )
|
||||||
source = this.i18n.t('emote.global', 'Twitch Global');
|
source = this.i18n.t('emote.global', 'Twitch Global');
|
||||||
|
|
||||||
else if ( type === EmoteTypes.LimitedTime )
|
else if ( type === EmoteTypes.LimitedTime )
|
||||||
|
@ -155,23 +219,30 @@ export default class EmoteCard extends Module {
|
||||||
amount: data.bitsBadgeTierSummary?.threshold,
|
amount: data.bitsBadgeTierSummary?.threshold,
|
||||||
source: data.owner.displayName || data.owner.login
|
source: data.owner.displayName || data.owner.login
|
||||||
});
|
});
|
||||||
|
body = 'twitch';
|
||||||
|
|
||||||
} else if ( type === EmoteTypes.TwoFactor )
|
} else if ( type === EmoteTypes.TwoFactor )
|
||||||
source = this.i18n.t('emote.2fa', 'Twitch 2FA Emote');
|
source = this.i18n.t('emote.2fa', 'Twitch 2FA Emote');
|
||||||
|
|
||||||
else if ( type === EmoteTypes.ChannelPoints )
|
else if ( type === EmoteTypes.ChannelPoints ) {
|
||||||
source = this.i18n.t('emote.points', 'Channel Points Emote');
|
source = this.i18n.t('emote.points', 'Channel Points Emote');
|
||||||
|
body = 'twitch';
|
||||||
|
|
||||||
else if ( type === EmoteTypes.Prime || type === EmoteTypes.Turbo )
|
} else if ( type === EmoteTypes.Prime || type === EmoteTypes.Turbo )
|
||||||
source = this.i18n.t('emote.prime', 'Prime Gaming');
|
source = this.i18n.t('emote.prime', 'Prime Gaming');
|
||||||
|
|
||||||
else
|
else
|
||||||
source = data.type;
|
source = data.type;
|
||||||
|
|
||||||
|
//console.log('raw data', data);
|
||||||
|
|
||||||
const out = {
|
const out = {
|
||||||
//raw: data,
|
//raw: data,
|
||||||
id: data.id,
|
id: data.id,
|
||||||
|
fav_source: 'twitch',
|
||||||
|
channel_id: data.owner?.id,
|
||||||
more: [],
|
more: [],
|
||||||
|
body,
|
||||||
src,
|
src,
|
||||||
srcSet,
|
srcSet,
|
||||||
name: data.token,
|
name: data.token,
|
||||||
|
@ -184,17 +255,129 @@ export default class EmoteCard extends Module {
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
|
|
||||||
/*if ( data.owner?.id )
|
if ( data.owner?.id ) {
|
||||||
|
out.channel_title = data.owner.displayName ?? data.owner.login;
|
||||||
|
out.channel_login = data.owner.login;
|
||||||
|
out.channel_live = !! data.owner.stream?.id;
|
||||||
|
out.channel_followed = !! data.owner?.self?.follower?.followedAt;
|
||||||
|
|
||||||
out.more.push({
|
out.more.push({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
icon: 'ffz-i-link-ext',
|
icon: 'ffz-i-link-ext',
|
||||||
title: 'View Channel on TwitchEmotes.com',
|
title: 'View Channel on TwitchEmotes.com',
|
||||||
href: `https://twitchemotes.com/channels/${data.owner.id}`
|
href: `https://twitchemotes.com/channels/${data.owner.id}`
|
||||||
});*/
|
});
|
||||||
|
|
||||||
|
// Check if we can actually submit a report.
|
||||||
|
if ( this.canReportTwitch() )
|
||||||
|
out.more.push({
|
||||||
|
type: 'report-twitch',
|
||||||
|
title_i18n: 'emote-card.report',
|
||||||
|
title: 'Report Emote',
|
||||||
|
icon: 'ffz-i-flag'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( data.bitsBadgeTierSummary?.threshold ) {
|
||||||
|
out.unlock_mode = 'bits';
|
||||||
|
out.unlocked = data.bitsBadgeTierSummary.self?.isUnlocked;
|
||||||
|
out.bits_amount = data.bitsBadgeTierSummary.threshold;
|
||||||
|
out.bits_remain = data.bitsBadgeTierSummary.self?.numberOfBitsUntilUnlock ?? out.unlock_amount;
|
||||||
|
|
||||||
|
} else if ( type === EmoteTypes.Follower ) {
|
||||||
|
out.unlock_mode = 'follow';
|
||||||
|
out.unlocked = false; // out.channel_followed ?? false;
|
||||||
|
const extras = out.extra_emotes = [];
|
||||||
|
|
||||||
|
if ( ! out.unlocked && Array.isArray(data.owner?.channel?.localEmoteSets) )
|
||||||
|
for(const set of data.owner.channel.localEmoteSets)
|
||||||
|
if ( Array.isArray(set.emotes) )
|
||||||
|
for(const em of set.emotes) {
|
||||||
|
const src = getTwitchEmoteURL(em.id, 1, true, true);
|
||||||
|
const srcSet = `${src} 1x, ${getTwitchEmoteURL(em.id, 2, true, true)} 2x`;
|
||||||
|
|
||||||
|
extras.push({
|
||||||
|
id: em.id,
|
||||||
|
name: em.token,
|
||||||
|
src,
|
||||||
|
srcSet
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if ( type === EmoteTypes.Subscription ) {
|
||||||
|
out.unlock_mode = 'subscribe';
|
||||||
|
out.unlocked = false;
|
||||||
|
out.unlock_tier = tier;
|
||||||
|
|
||||||
|
out.existing_tier = 0;
|
||||||
|
const bene = data.owner?.self?.subscriptionBenefit;
|
||||||
|
if ( bene?.tier )
|
||||||
|
out.existing_tier = tierToNumber(bene.tier);
|
||||||
|
|
||||||
|
const extras = out.extra_emotes = [],
|
||||||
|
extier = out.existing_tier;
|
||||||
|
|
||||||
|
if ( extier >= tier )
|
||||||
|
out.unlocked = true;
|
||||||
|
else if ( Array.isArray(data.owner?.subscriptionProducts) )
|
||||||
|
for(const product of data.owner.subscriptionProducts) {
|
||||||
|
const ptier = tierToNumber(product.tier);
|
||||||
|
if ( ptier === tier ) {
|
||||||
|
out.channel_product = product.name;
|
||||||
|
if ( product.priceInfo?.price && product.priceInfo.currency ) {
|
||||||
|
const formatter = new Intl.NumberFormat(navigator.languages, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: product.priceInfo.currency
|
||||||
|
});
|
||||||
|
|
||||||
|
out.product_price = formatter.format(product.priceInfo.price / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ptier > extier && ptier <= tier && Array.isArray(product.emotes) )
|
||||||
|
for(const em of product.emotes) {
|
||||||
|
const src = getTwitchEmoteURL(em.id, 1, true, true);
|
||||||
|
const srcSet = `${src} 1x, ${getTwitchEmoteURL(em.id, 2, true, true)} 2x`;
|
||||||
|
|
||||||
|
extras.push({
|
||||||
|
id: em.id,
|
||||||
|
name: em.token,
|
||||||
|
src,
|
||||||
|
srcSet
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emoji
|
||||||
|
if ( emote.provider === 'emoji' ) {
|
||||||
|
const emoji = this.emoji.emoji[emote.code],
|
||||||
|
style = this.chat.context.get('chat.emoji.style'),
|
||||||
|
variant = emote.variant ? emoji.variants[emote.variant] : emoji,
|
||||||
|
vcode = emote.variant ? this.emoji.emoji[emote.variant] : null;
|
||||||
|
|
||||||
|
const category = emoji.category ? this.i18n.t(`emoji.category.${emoji.category.toSnakeCase()}`, this.emoji.categories[emoji.category] || emoji.category) : null;
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
id: emote.code,
|
||||||
|
fav_source: 'emoji',
|
||||||
|
more: [],
|
||||||
|
src: this.emoji.getFullImage(variant.image, style),
|
||||||
|
srcSet: this.emoji.getFullImageSet(variant.image, style),
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
name: `:${emoji.names[0]}:${vcode ? `:${vcode.names[0]}:` : ''}`,
|
||||||
|
source: this.i18n.t('tooltip.emoji', 'Emoji - {category}', {category})
|
||||||
|
};
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( emote.provider !== 'ffz' )
|
||||||
|
throw new Error('Invalid provider');
|
||||||
|
|
||||||
// Try to get the emote set.
|
// Try to get the emote set.
|
||||||
const emote_set = this.emotes.emote_sets[emote.set],
|
const emote_set = this.emotes.emote_sets[emote.set],
|
||||||
|
@ -205,6 +388,7 @@ export default class EmoteCard extends Module {
|
||||||
|
|
||||||
const out = {
|
const out = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
|
fav_source: emote_set.source ?? 'ffz',
|
||||||
more: [],
|
more: [],
|
||||||
src: data.animSrc2 ?? data.src2,
|
src: data.animSrc2 ?? data.src2,
|
||||||
srcSet: data.animSrcSet2 ?? data.srcSet2,
|
srcSet: data.animSrcSet2 ?? data.srcSet2,
|
||||||
|
@ -215,18 +399,21 @@ export default class EmoteCard extends Module {
|
||||||
owner: data.owner
|
owner: data.owner
|
||||||
? (data.owner.display_name || data.owner.name)
|
? (data.owner.display_name || data.owner.name)
|
||||||
: null,
|
: null,
|
||||||
ownerLink: data.owner
|
ownerLink: data.owner && ! emote_set.source
|
||||||
? `https://www.frankerfacez.com/${data.owner.name}`
|
? `https://www.frankerfacez.com/${data.owner.name}`
|
||||||
: null,
|
: null,
|
||||||
artist: data.artist
|
artist: data.artist
|
||||||
? (data.artist.display_name || data.artist.name)
|
? (data.artist.display_name || data.artist.name)
|
||||||
: null,
|
: null,
|
||||||
artistLink: data.artist
|
artistLink: data.artist && ! emote_set.source
|
||||||
? `https://www.frankerfacez.com/${data.artist.name}`
|
? `https://www.frankerfacez.com/${data.artist.name}`
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ( ! emote_set.source ) {
|
if ( ! emote_set.source ) {
|
||||||
|
if ( data.public )
|
||||||
|
out.body = 'manage-ffz';
|
||||||
|
|
||||||
out.more.push({
|
out.more.push({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
title_i18n: 'emote-card.view-on-ffz',
|
title_i18n: 'emote-card.view-on-ffz',
|
||||||
|
@ -240,15 +427,24 @@ export default class EmoteCard extends Module {
|
||||||
title: 'Report Emote',
|
title: 'Report Emote',
|
||||||
icon: 'ffz-i-flag'
|
icon: 'ffz-i-flag'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} else if ( data.click_url ) {
|
||||||
|
out.more.push({
|
||||||
|
type: 'link',
|
||||||
|
title_i18n: 'emote-card.view-external',
|
||||||
|
title: 'View on {source}',
|
||||||
|
source: emote_set.source,
|
||||||
|
href: data.click_url
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async openCard(emote, event) {
|
async openCard(emote, modifiers, event) {
|
||||||
|
|
||||||
const card_key = `${emote.provider}::${emote.id}`,
|
const card_key = `${emote.provider}::${emote.id}::${modifiers ?? ''}`,
|
||||||
old_card = this.open_cards[card_key];
|
old_card = this.open_cards[card_key];
|
||||||
|
|
||||||
if ( old_card ) {
|
if ( old_card ) {
|
||||||
|
@ -283,11 +479,12 @@ export default class EmoteCard extends Module {
|
||||||
pos_x,
|
pos_x,
|
||||||
pos_y,
|
pos_y,
|
||||||
emote,
|
emote,
|
||||||
|
modifiers,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildCard(pos_x, pos_y, emote, data) {
|
buildCard(pos_x, pos_y, emote, modifiers, data) {
|
||||||
let child;
|
let child;
|
||||||
|
|
||||||
const component = new this.vue.Vue({
|
const component = new this.vue.Vue({
|
||||||
|
@ -295,9 +492,11 @@ export default class EmoteCard extends Module {
|
||||||
render: h => h('emote-card', {
|
render: h => h('emote-card', {
|
||||||
props: {
|
props: {
|
||||||
raw_emote: deep_copy(emote),
|
raw_emote: deep_copy(emote),
|
||||||
|
raw_modifiers: modifiers,
|
||||||
data: data,
|
data: data,
|
||||||
|
|
||||||
getFFZ: () => this,
|
getFFZ: () => this,
|
||||||
|
reportTwitchEmote: (...args) => this.reportTwitchEmote(...args),
|
||||||
getZ: () => ++this.last_z
|
getZ: () => ++this.last_z
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -312,7 +511,7 @@ export default class EmoteCard extends Module {
|
||||||
if ( this.last_card === child )
|
if ( this.last_card === child )
|
||||||
this.last_card = null;
|
this.last_card = null;
|
||||||
|
|
||||||
const card_key = `${emote.provider}::${emote.id}`;
|
const card_key = `${emote.provider}::${emote.id}::${modifiers ?? ''}`;
|
||||||
if ( this.open_cards[card_key] === child )
|
if ( this.open_cards[card_key] === child )
|
||||||
this.open_cards[card_key] = null;
|
this.open_cards[card_key] = null;
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
<label :for="item.full_key" class="ffz-checkbox__label">
|
<label :for="item.full_key" class="ffz-checkbox__label">
|
||||||
<span class="tw-mg-l-1">
|
<span class="tw-mg-l-1">
|
||||||
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
||||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
<span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="tw-flex tw-align-items-center">
|
<div class="tw-flex tw-align-items-center">
|
||||||
<label :for="item.full_key">
|
<label :for="item.full_key">
|
||||||
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
||||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
<span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<color-picker
|
<color-picker
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="tw-flex tw-align-items-start">
|
<div class="tw-flex tw-align-items-start">
|
||||||
<label :for="item.full_key" class="tw-mg-y-05">
|
<label :for="item.full_key" class="tw-mg-y-05">
|
||||||
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
||||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
<span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="tw-flex tw-flex-column tw-mg-05">
|
<div class="tw-flex tw-flex-column tw-mg-05">
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="tw-flex tw-align-items-center">
|
<div class="tw-flex tw-align-items-center">
|
||||||
<label :for="item.full_key">
|
<label :for="item.full_key">
|
||||||
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
||||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
<span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<key-picker
|
<key-picker
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="tw-flex tw-align-items-center">
|
<div class="tw-flex tw-align-items-center">
|
||||||
<label :for="item.full_key" class="tw-mg-y-05">
|
<label :for="item.full_key" class="tw-mg-y-05">
|
||||||
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
||||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
<span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="tw-flex tw-align-items-center">
|
<div class="tw-flex tw-align-items-center">
|
||||||
<label :for="item.full_key">
|
<label :for="item.full_key">
|
||||||
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
|
||||||
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span>
|
<span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|
|
@ -232,6 +232,8 @@ Twilight.KNOWN_MODULES = {
|
||||||
if ( n.S && n.S.toString().includes('.visit') )
|
if ( n.S && n.S.toString().includes('.visit') )
|
||||||
return n.S;
|
return n.S;
|
||||||
},
|
},
|
||||||
|
'user-report': n => n['a3']?.displayName === 'Loadable(ReportUserModal)' && n['a3'],
|
||||||
|
'sub-form': n => typeof n.T === 'function' && String(n.T).includes('CheckoutModal') && n.T,
|
||||||
mousetrap: n => n.bindGlobal && n.unbind && n.handleKey,
|
mousetrap: n => n.bindGlobal && n.unbind && n.handleKey,
|
||||||
'algolia-search': n => {
|
'algolia-search': n => {
|
||||||
if ( n.a?.prototype?.queryTopResults && n.a.prototype.queryForType )
|
if ( n.a?.prototype?.queryTopResults && n.a.prototype.queryForType )
|
||||||
|
@ -279,6 +281,11 @@ Twilight.KNOWN_MODULES['highlightstack'].chunks = CHAT_CHUNK;
|
||||||
Twilight.KNOWN_MODULES['algolia-search'].use_result = true;
|
Twilight.KNOWN_MODULES['algolia-search'].use_result = true;
|
||||||
Twilight.KNOWN_MODULES['algolia-search'].chunks = 'core';
|
Twilight.KNOWN_MODULES['algolia-search'].chunks = 'core';
|
||||||
|
|
||||||
|
Twilight.KNOWN_MODULES['user-report'].use_result = true;
|
||||||
|
Twilight.KNOWN_MODULES['user-report'].chunks = 'core';
|
||||||
|
|
||||||
|
Twilight.KNOWN_MODULES['sub-form'].use_result = true;
|
||||||
|
Twilight.KNOWN_MODULES['sub-form'].chunks = 'core';
|
||||||
|
|
||||||
|
|
||||||
Twilight.POPOUT_ROUTES = [
|
Twilight.POPOUT_ROUTES = [
|
||||||
|
|
|
@ -330,7 +330,6 @@ export default class Input extends Module {
|
||||||
this.on('chat.emotes:update-default-sets', this.uncacheTabCompletion, this);
|
this.on('chat.emotes:update-default-sets', this.uncacheTabCompletion, this);
|
||||||
this.on('chat.emotes:update-user-sets', this.uncacheTabCompletion, this);
|
this.on('chat.emotes:update-user-sets', this.uncacheTabCompletion, this);
|
||||||
this.on('chat.emotes:update-room-sets', this.uncacheTabCompletion, this);
|
this.on('chat.emotes:update-room-sets', this.uncacheTabCompletion, this);
|
||||||
|
|
||||||
this.on('site.css_tweaks:update-chat-css', this.resizeInput, this);
|
this.on('site.css_tweaks:update-chat-css', this.resizeInput, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -341,8 +340,11 @@ export default class Input extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( this.use_previews )
|
if ( this.use_previews )
|
||||||
for(const inst of this.ChatInput.instances)
|
for(const inst of this.ChatInput.instances) {
|
||||||
inst.ffzInjectEmotes();
|
inst.ffzInjectEmotes();
|
||||||
|
inst.forceUpdate();
|
||||||
|
this.emit('site:dom-update', 'chat-input', inst);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateInput() {
|
updateInput() {
|
||||||
|
@ -460,10 +462,32 @@ export default class Input extends Module {
|
||||||
if ( outer ) {
|
if ( outer ) {
|
||||||
outer.style.width = w;
|
outer.style.width = w;
|
||||||
outer.style.height = h;
|
outer.style.height = h;
|
||||||
|
|
||||||
|
if ( ! outer._ffz_click_handler ) {
|
||||||
|
outer._ffz_click_handler = this.previewClick.bind(this, emote.id, emote_set.id, emote.name);
|
||||||
|
outer.addEventListener('click', outer._ffz_click_handler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previewClick(id, set, name, evt) {
|
||||||
|
const fe = new FFZEvent({
|
||||||
|
provider: 'ffz',
|
||||||
|
id,
|
||||||
|
set,
|
||||||
|
name,
|
||||||
|
source: evt
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit('chat.emotes:click', fe);
|
||||||
|
if ( ! fe.defaultPrevented )
|
||||||
|
return;
|
||||||
|
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
removePreviewObserver(inst) {
|
removePreviewObserver(inst) {
|
||||||
if ( inst._ffz_preview_observer ) {
|
if ( inst._ffz_preview_observer ) {
|
||||||
inst._ffz_preview_observer.disconnect();
|
inst._ffz_preview_observer.disconnect();
|
||||||
|
@ -505,6 +529,11 @@ export default class Input extends Module {
|
||||||
this.props.emotes.splice(idx, 1, data);
|
this.props.emotes.splice(idx, 1, data);
|
||||||
else if ( idx !== -1 && ! data )
|
else if ( idx !== -1 && ! data )
|
||||||
this.props.emotes.splice(idx, 1);
|
this.props.emotes.splice(idx, 1);
|
||||||
|
else
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Make a copy, so that React reacts.
|
||||||
|
this.props.emotes = [...this.props.emotes];
|
||||||
}
|
}
|
||||||
|
|
||||||
inst.componentDidUpdate = function(props, ...args) {
|
inst.componentDidUpdate = function(props, ...args) {
|
||||||
|
|
|
@ -7,6 +7,10 @@
|
||||||
box-shadow: var(--shadow-button-focus);
|
box-shadow: var(--shadow-button-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
overflow: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
> div:first-child {
|
> div:first-child {
|
||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
@ -42,4 +46,26 @@
|
||||||
.viewer-card__background {
|
.viewer-card__background {
|
||||||
background-position: top;
|
background-position: top;
|
||||||
background-size: cover
|
background-size: cover
|
||||||
|
}
|
||||||
|
|
||||||
|
.ffz-emote-card__live-indicator {
|
||||||
|
background-color: var(--color-fill-live);
|
||||||
|
border-radius: var(--border-radius-rounded);
|
||||||
|
width: 0.8rem;
|
||||||
|
height: 0.8rem;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ffz-emote-card__emote-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ffz-button__sub-price {
|
||||||
|
background-color: var(--color-background-button-purchase);
|
||||||
|
padding: 0 var(--button-padding-x);
|
||||||
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
149
src/std-components/follow-button.vue
Normal file
149
src/std-components/follow-button.vue
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<template v-if="error">
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
class="tw-button tw-button--disabled"
|
||||||
|
>
|
||||||
|
<span class="tw-button__text">
|
||||||
|
{{ t('follow-btn.error', 'Error') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
v-if="following"
|
||||||
|
:disabled="loading"
|
||||||
|
:class="{'tw-button--disabled': loading}"
|
||||||
|
:data-title="t('follow-btn.unfollow', 'Unfollow')"
|
||||||
|
class="tw-button tw-button--status tw-button--success ffz-tooltip ffz--featured-button-unfollow"
|
||||||
|
@click="unfollowUser"
|
||||||
|
>
|
||||||
|
<span class="tw-button__icon tw-button__icon--status tw-flex">
|
||||||
|
<figure class="ffz-i-heart ffz--featured-button-unfollow-button" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
:disabled="loading"
|
||||||
|
:class="{'tw-button--disabled': loading}"
|
||||||
|
class="tw-button"
|
||||||
|
@click="followUser"
|
||||||
|
>
|
||||||
|
<span class="tw-button__icon tw-button__icon--left">
|
||||||
|
<figure class="ffz-i-heart" />
|
||||||
|
</span>
|
||||||
|
<span class="tw-button__text">
|
||||||
|
{{ t('follow-btn.follow', 'Follow') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
|
||||||
|
props: [
|
||||||
|
'channel',
|
||||||
|
'initial',
|
||||||
|
'initial-notif',
|
||||||
|
'show-notif'
|
||||||
|
],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
|
following: this.initial,
|
||||||
|
notifications: this.initialNotif
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.twitch_data = FrankerFaceZ.get().resolve('site.twitch_data');
|
||||||
|
|
||||||
|
if ( this.following == null || (this.notifications == null && this.showNotif) )
|
||||||
|
this.checkFollowing();
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async checkFollowing() {
|
||||||
|
if ( this.loading )
|
||||||
|
return;
|
||||||
|
|
||||||
|
if ( ! this.twitch_data ) {
|
||||||
|
this.error = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
let following;
|
||||||
|
try {
|
||||||
|
following = await this.twitch_data.getUserFollowed(this.channel);
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
this.error = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.following = !! following?.followedAt;
|
||||||
|
this.notifications = !! following.disableNotifications;
|
||||||
|
},
|
||||||
|
|
||||||
|
async followUser() {
|
||||||
|
if ( this.loading )
|
||||||
|
return;
|
||||||
|
|
||||||
|
if ( ! this.twitch_data ) {
|
||||||
|
this.error = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
let following;
|
||||||
|
try {
|
||||||
|
following = await this.twitch_data.followUser(this.channel);
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
this.error = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.following = !! following?.followedAt;
|
||||||
|
this.notifications = !! following.disableNotifications;
|
||||||
|
},
|
||||||
|
|
||||||
|
async unfollowUser() {
|
||||||
|
if ( this.loading )
|
||||||
|
return;
|
||||||
|
|
||||||
|
if ( ! this.twitch_data ) {
|
||||||
|
this.error = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.twitch_data.unfollowUser(this.channel);
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err);
|
||||||
|
this.error = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.following = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
11
src/utilities/data/follow-user.gql
Normal file
11
src/utilities/data/follow-user.gql
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
mutation FFZ_FollowUser($input: FollowUserInput!) {
|
||||||
|
followUser(input: $input) {
|
||||||
|
follow {
|
||||||
|
disableNotifications
|
||||||
|
followedAt
|
||||||
|
}
|
||||||
|
error {
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
8
src/utilities/data/unfollow-user.gql
Normal file
8
src/utilities/data/unfollow-user.gql
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
mutation FFZ_UnfollowUser($input: UnfollowUserInput!) {
|
||||||
|
unfollowUser(input: $input) {
|
||||||
|
follow {
|
||||||
|
disableNotifications
|
||||||
|
followedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
src/utilities/data/user-followed.gql
Normal file
11
src/utilities/data/user-followed.gql
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
query FFZ_UserFollowed($id: ID, $login: String) {
|
||||||
|
user(id: $id, login: $login) {
|
||||||
|
id
|
||||||
|
self {
|
||||||
|
follower {
|
||||||
|
disableNotifications
|
||||||
|
followedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -284,6 +284,58 @@ export default class TwitchData extends Module {
|
||||||
return get('data.user.self', data);
|
return get('data.user.self', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async getUserFollowed(id, login) {
|
||||||
|
const data = await this.queryApollo(
|
||||||
|
await import(/* webpackChunkName: 'queries' */ './data/user-followed.gql'),
|
||||||
|
{ id, login }
|
||||||
|
);
|
||||||
|
|
||||||
|
return get('data.user.self.follower', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async followUser(channel_id, disable_notifications = false) {
|
||||||
|
channel_id = String(channel_id);
|
||||||
|
disable_notifications = !! disable_notifications;
|
||||||
|
|
||||||
|
const data = await this.mutate({
|
||||||
|
mutation: await import(/* webpackChunkName: 'queries' */ './data/follow-user.gql'),
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
targetID: channel_id,
|
||||||
|
disableNotifications: disable_notifications
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('result', data);
|
||||||
|
const err = get('data.followUser.error', data);
|
||||||
|
if ( err?.code )
|
||||||
|
throw new Error(err.code);
|
||||||
|
|
||||||
|
return get('data.followUser.follow', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async unfollowUser(channel_id, disable_notifications = false) {
|
||||||
|
channel_id = String(channel_id);
|
||||||
|
disable_notifications = !! disable_notifications;
|
||||||
|
|
||||||
|
const data = await this.mutate({
|
||||||
|
mutation: await import(/* webpackChunkName: 'queries' */ './data/unfollow-user.gql'),
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
targetID: channel_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('result', data);
|
||||||
|
return get('data.unfollowUser.follow', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries Apollo for the requested user's latest broadcast. One of (id, login) MUST be specified
|
* Queries Apollo for the requested user's latest broadcast. One of (id, login) MUST be specified
|
||||||
* @function getLastBroadcast
|
* @function getLastBroadcast
|
||||||
|
|
|
@ -15,6 +15,26 @@
|
||||||
100% { transform: rotateY(90deg) rotateX(0deg) }
|
100% { transform: rotateY(90deg) rotateX(0deg) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes ffz-gentle-shake {
|
||||||
|
0% { transform: translate(20px) }
|
||||||
|
20% { transform: translate(-20px) }
|
||||||
|
40% { transform: translate(10px) }
|
||||||
|
60% { transform: translate(-10px) }
|
||||||
|
80% { transform: translate(5px) }
|
||||||
|
100% { transform: translate(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ffz--fade {
|
||||||
|
0% { opacity: 1 }
|
||||||
|
50% { opacity: 0.5 }
|
||||||
|
100% { opacity: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ffz-rotate {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.ffz--inline {
|
.ffz--inline {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
@ -64,6 +84,19 @@
|
||||||
right: 0.25rem;
|
right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ffz--rotate {
|
||||||
|
animation: ffz-rotate 2s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ffz--shaking {
|
||||||
|
animation: ffz-gentle-shake 0.4s 1 linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ffz--shaking {
|
||||||
|
animation: fade 0.4s 1 linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ffz-i-t-reset.loading,
|
.ffz-i-t-reset.loading,
|
||||||
.ffz-i-t-reset-clicked.loading,
|
.ffz-i-t-reset-clicked.loading,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue