mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 21:05:53 +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",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.44.1",
|
||||
"version": "4.45.0",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
@ -70,8 +70,8 @@
|
|||
"file-saver": "^2.0.5",
|
||||
"graphql": "^16.0.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"jszip": "^3.7.1",
|
||||
"js-cookie": "^2.2.1",
|
||||
"jszip": "^3.7.1",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it-link-attributes": "^3.0.0",
|
||||
"mnemonist": "^0.38.5",
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
"name": "Modular Chat Line Rendering",
|
||||
"description": "Enable a newer, modular chat line renderer.",
|
||||
"groups": [
|
||||
{"value": true, "weight": 80},
|
||||
{"value": false, "weight": 20}
|
||||
{"value": true, "weight": 100},
|
||||
{"value": false, "weight": 0}
|
||||
]
|
||||
},
|
||||
"api_load": {
|
||||
|
|
|
@ -933,7 +933,7 @@ export default class Emotes extends Module {
|
|||
else if ( ! value && idx !== -1 )
|
||||
favorites.splice(idx, 1);
|
||||
else
|
||||
return;
|
||||
return value;
|
||||
|
||||
if ( favorites.length )
|
||||
p.set(key, favorites);
|
||||
|
@ -941,6 +941,7 @@ export default class Emotes extends Module {
|
|||
p.delete(key);
|
||||
|
||||
this.emit(':change-favorite', source, id, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
isFavorite(source, id) {
|
||||
|
@ -1075,11 +1076,21 @@ export default class Emotes extends Module {
|
|||
if ( favorite_only )
|
||||
return false;
|
||||
|
||||
let modifiers;
|
||||
try {
|
||||
modifiers = JSON.parse(ds.modifierInfo);
|
||||
} catch(err) {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
const evt = new FFZEvent({
|
||||
provider,
|
||||
id: ds.id,
|
||||
set: ds.set,
|
||||
code: ds.code,
|
||||
variant: ds.variant,
|
||||
name: ds.name || target.alt,
|
||||
modifiers,
|
||||
source: event
|
||||
});
|
||||
|
||||
|
@ -1680,7 +1691,7 @@ export default class Emotes extends Module {
|
|||
animSrc2: emote.animSrc2,
|
||||
animSrcSet2: emote.animSrcSet2,
|
||||
masked: !! emote.mask,
|
||||
hidden: (emote.modifier_flags & 1) === 1,
|
||||
mod_hidden: (emote.modifier_flags & 1) === 1,
|
||||
text: emote.hidden ? '???' : emote.name,
|
||||
length: emote.name.length,
|
||||
height: emote.height,
|
||||
|
|
|
@ -1260,7 +1260,7 @@ export const AddonEmotes = {
|
|||
if ( mod.effect_bg )
|
||||
as_bg = true;
|
||||
|
||||
if ( ! mod.hidden && mod.set !== 'info' ) {
|
||||
if ( ! mod.mod_hidden && mod.set !== 'info' ) {
|
||||
const factor = mod.big ? 2 : 1,
|
||||
width = mod.width * factor,
|
||||
height = mod.height * factor;
|
||||
|
|
|
@ -1,37 +1,44 @@
|
|||
<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"
|
||||
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"
|
||||
@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"
|
||||
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-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">
|
||||
<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
|
||||
v-if="loaded && emote.src"
|
||||
v-if="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">
|
||||
<h4 class="tw-inline tw-ellipsis" :title="emote ? emote.name : raw_emote.name">
|
||||
{{ emote ? emote.name : raw_emote.name }}
|
||||
</h4>
|
||||
<P
|
||||
v-if="! loaded"
|
||||
class="tw-c-text-alt-2 tw-font-size-6"
|
||||
>
|
||||
{{ t('emote-card.loading', 'Loading...') }}
|
||||
</P>
|
||||
<p
|
||||
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"
|
||||
>
|
||||
{{ 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">
|
||||
<p v-if="loaded && emote.owner" class="tw-c-text-alt-2 tw-font-size-6 tw-ellipsis">
|
||||
<t-list
|
||||
phrase="emote-card.owner"
|
||||
default="Owner: {owner}"
|
||||
|
@ -47,7 +54,7 @@
|
|||
</template>
|
||||
</t-list>
|
||||
</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
|
||||
phrase="emote-card.artist"
|
||||
default="Artist: {artist}"
|
||||
|
@ -65,6 +72,20 @@
|
|||
</t-list>
|
||||
</p>
|
||||
</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">
|
||||
<button
|
||||
:data-title="t('emote-card.close', 'Close')"
|
||||
|
@ -96,9 +117,10 @@
|
|||
color="background-alt-2"
|
||||
dir="down-right"
|
||||
size="sm"
|
||||
class="tw-border-radius-medium"
|
||||
>
|
||||
<simplebar classes="ffz-mh-30">
|
||||
<div class="tw-pd-y-1">
|
||||
<div class="tw-pd-y-05">
|
||||
<template v-for="(entry, idx) in moreActions">
|
||||
<div
|
||||
v-if="entry.divider"
|
||||
|
@ -142,24 +164,41 @@
|
|||
:getFFZ="getFFZ"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import ManageFFZ from './manage-ffz.vue';
|
||||
import Modifiers from './modifiers.vue';
|
||||
import ReportForm from './report-form.vue';
|
||||
import TwitchBody from './twitch-body.vue';
|
||||
|
||||
import displace from 'displacejs';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Modifiers,
|
||||
ReportForm
|
||||
},
|
||||
|
||||
props: [
|
||||
'raw_emote', 'data',
|
||||
'pos_x', 'pos_y',
|
||||
'getZ', 'getFFZ'
|
||||
'getZ', 'getFFZ', 'reportTwitchEmote',
|
||||
'raw_modifiers'
|
||||
],
|
||||
|
||||
data() {
|
||||
|
@ -167,6 +206,7 @@ export default {
|
|||
z: this.getZ(),
|
||||
|
||||
moreOpen: false,
|
||||
isFavorite: false,
|
||||
|
||||
reporting: false,
|
||||
|
||||
|
@ -179,6 +219,30 @@ export default {
|
|||
},
|
||||
|
||||
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() {
|
||||
if ( ! this.loaded || ! this.emote.more )
|
||||
return null;
|
||||
|
@ -209,6 +273,9 @@ export default {
|
|||
this.ffzEmit(':load', this);
|
||||
this.emote = data;
|
||||
|
||||
this.updateIsFavorite();
|
||||
this.$nextTick(() => this.handleResize());
|
||||
|
||||
}).catch(err => {
|
||||
console.error('Error loading emote card data', err);
|
||||
this.errored = true;
|
||||
|
@ -231,6 +298,24 @@ export default {
|
|||
},
|
||||
|
||||
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() {
|
||||
this.moreOpen = ! this.moreOpen;
|
||||
},
|
||||
|
@ -249,7 +334,14 @@ export default {
|
|||
|
||||
if ( entry.type === 'report-ffz' )
|
||||
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() {
|
||||
|
|
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
|
||||
// ============================================================================
|
||||
|
||||
import {createElement, sanitize} from 'utilities/dom';
|
||||
import {has, maybe_call, deep_copy, getTwitchEmoteURL} from 'utilities/object';
|
||||
import {createElement} from 'utilities/dom';
|
||||
import {deep_copy, getTwitchEmoteURL} from 'utilities/object';
|
||||
import { EmoteTypes } from 'utilities/constants';
|
||||
|
||||
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 {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
@ -43,6 +54,7 @@ export default class EmoteCard extends Module {
|
|||
this.inject('i18n');
|
||||
this.inject('chat');
|
||||
this.inject('chat.emotes');
|
||||
this.inject('chat.emoji');
|
||||
this.inject('site');
|
||||
this.inject('site.apollo');
|
||||
this.inject('site.twitch_data');
|
||||
|
@ -68,9 +80,12 @@ export default class EmoteCard extends Module {
|
|||
|
||||
this.openCard({
|
||||
provider: evt.provider,
|
||||
code: evt.code,
|
||||
variant: evt.variant,
|
||||
name: evt.name,
|
||||
set: evt.set,
|
||||
id: evt.id
|
||||
}, evt.source);
|
||||
id: evt.id ?? `${evt.code}::${evt.variant}`
|
||||
}, evt.modifiers, evt.source);
|
||||
|
||||
}
|
||||
|
||||
|
@ -79,12 +94,66 @@ export default class EmoteCard extends Module {
|
|||
return;
|
||||
|
||||
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_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) {
|
||||
if ( emote.provider === 'twitch' ) {
|
||||
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`;
|
||||
|
||||
let source;
|
||||
let body;
|
||||
let tier;
|
||||
|
||||
//console.log("loaded data", data);
|
||||
|
||||
|
@ -114,37 +185,30 @@ export default class EmoteCard extends Module {
|
|||
|
||||
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;
|
||||
tier = tierToNumber(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 )
|
||||
body = 'twitch';
|
||||
|
||||
} else if ( type === EmoteTypes.Follower ) {
|
||||
source = this.i18n.t('emote.follower', 'Follower Emote ({source})', {
|
||||
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');
|
||||
|
||||
else if ( type === EmoteTypes.LimitedTime )
|
||||
|
@ -155,23 +219,30 @@ export default class EmoteCard extends Module {
|
|||
amount: data.bitsBadgeTierSummary?.threshold,
|
||||
source: data.owner.displayName || data.owner.login
|
||||
});
|
||||
body = 'twitch';
|
||||
|
||||
} else if ( type === EmoteTypes.TwoFactor )
|
||||
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');
|
||||
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');
|
||||
|
||||
else
|
||||
source = data.type;
|
||||
|
||||
//console.log('raw data', data);
|
||||
|
||||
const out = {
|
||||
//raw: data,
|
||||
id: data.id,
|
||||
fav_source: 'twitch',
|
||||
channel_id: data.owner?.id,
|
||||
more: [],
|
||||
body,
|
||||
src,
|
||||
srcSet,
|
||||
name: data.token,
|
||||
|
@ -184,17 +255,129 @@ export default class EmoteCard extends Module {
|
|||
: 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({
|
||||
type: 'link',
|
||||
icon: 'ffz-i-link-ext',
|
||||
title: 'View Channel on TwitchEmotes.com',
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
const emote_set = this.emotes.emote_sets[emote.set],
|
||||
|
@ -205,6 +388,7 @@ export default class EmoteCard extends Module {
|
|||
|
||||
const out = {
|
||||
id: data.id,
|
||||
fav_source: emote_set.source ?? 'ffz',
|
||||
more: [],
|
||||
src: data.animSrc2 ?? data.src2,
|
||||
srcSet: data.animSrcSet2 ?? data.srcSet2,
|
||||
|
@ -215,18 +399,21 @@ export default class EmoteCard extends Module {
|
|||
owner: data.owner
|
||||
? (data.owner.display_name || data.owner.name)
|
||||
: null,
|
||||
ownerLink: data.owner
|
||||
ownerLink: data.owner && ! emote_set.source
|
||||
? `https://www.frankerfacez.com/${data.owner.name}`
|
||||
: null,
|
||||
artist: data.artist
|
||||
? (data.artist.display_name || data.artist.name)
|
||||
: null,
|
||||
artistLink: data.artist
|
||||
artistLink: data.artist && ! emote_set.source
|
||||
? `https://www.frankerfacez.com/${data.artist.name}`
|
||||
: null,
|
||||
};
|
||||
|
||||
if ( ! emote_set.source ) {
|
||||
if ( data.public )
|
||||
out.body = 'manage-ffz';
|
||||
|
||||
out.more.push({
|
||||
type: 'link',
|
||||
title_i18n: 'emote-card.view-on-ffz',
|
||||
|
@ -240,15 +427,24 @@ export default class EmoteCard extends Module {
|
|||
title: 'Report Emote',
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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];
|
||||
|
||||
if ( old_card ) {
|
||||
|
@ -283,11 +479,12 @@ export default class EmoteCard extends Module {
|
|||
pos_x,
|
||||
pos_y,
|
||||
emote,
|
||||
modifiers,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
buildCard(pos_x, pos_y, emote, data) {
|
||||
buildCard(pos_x, pos_y, emote, modifiers, data) {
|
||||
let child;
|
||||
|
||||
const component = new this.vue.Vue({
|
||||
|
@ -295,9 +492,11 @@ export default class EmoteCard extends Module {
|
|||
render: h => h('emote-card', {
|
||||
props: {
|
||||
raw_emote: deep_copy(emote),
|
||||
raw_modifiers: modifiers,
|
||||
data: data,
|
||||
|
||||
getFFZ: () => this,
|
||||
reportTwitchEmote: (...args) => this.reportTwitchEmote(...args),
|
||||
getZ: () => ++this.last_z
|
||||
},
|
||||
|
||||
|
@ -312,7 +511,7 @@ export default class EmoteCard extends Module {
|
|||
if ( this.last_card === child )
|
||||
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 )
|
||||
this.open_cards[card_key] = null;
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<label :for="item.full_key" class="ffz-checkbox__label">
|
||||
<span class="tw-mg-l-1">
|
||||
{{ 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>
|
||||
</label>
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<div class="tw-flex tw-align-items-center">
|
||||
<label :for="item.full_key">
|
||||
{{ 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>
|
||||
|
||||
<color-picker
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<div class="tw-flex tw-align-items-start">
|
||||
<label :for="item.full_key" class="tw-mg-y-05">
|
||||
{{ 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>
|
||||
|
||||
<div class="tw-flex tw-flex-column tw-mg-05">
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<div class="tw-flex tw-align-items-center">
|
||||
<label :for="item.full_key">
|
||||
{{ 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>
|
||||
|
||||
<key-picker
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<div class="tw-flex tw-align-items-center">
|
||||
<label :for="item.full_key" class="tw-mg-y-05">
|
||||
{{ 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>
|
||||
|
||||
<select
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<div class="tw-flex tw-align-items-center">
|
||||
<label :for="item.full_key">
|
||||
{{ 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>
|
||||
|
||||
<input
|
||||
|
|
|
@ -232,6 +232,8 @@ Twilight.KNOWN_MODULES = {
|
|||
if ( n.S && n.S.toString().includes('.visit') )
|
||||
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,
|
||||
'algolia-search': n => {
|
||||
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'].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 = [
|
||||
|
|
|
@ -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-user-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);
|
||||
}
|
||||
|
||||
|
@ -341,8 +340,11 @@ export default class Input extends Module {
|
|||
}
|
||||
|
||||
if ( this.use_previews )
|
||||
for(const inst of this.ChatInput.instances)
|
||||
for(const inst of this.ChatInput.instances) {
|
||||
inst.ffzInjectEmotes();
|
||||
inst.forceUpdate();
|
||||
this.emit('site:dom-update', 'chat-input', inst);
|
||||
}
|
||||
}
|
||||
|
||||
updateInput() {
|
||||
|
@ -460,10 +462,32 @@ export default class Input extends Module {
|
|||
if ( outer ) {
|
||||
outer.style.width = w;
|
||||
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) {
|
||||
if ( inst._ffz_preview_observer ) {
|
||||
inst._ffz_preview_observer.disconnect();
|
||||
|
@ -505,6 +529,11 @@ export default class Input extends Module {
|
|||
this.props.emotes.splice(idx, 1, data);
|
||||
else if ( idx !== -1 && ! data )
|
||||
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) {
|
||||
|
|
|
@ -7,6 +7,10 @@
|
|||
box-shadow: var(--shadow-button-focus);
|
||||
}
|
||||
|
||||
&__header {
|
||||
overflow: unset !important;
|
||||
}
|
||||
|
||||
> div:first-child {
|
||||
cursor: move;
|
||||
}
|
||||
|
@ -42,4 +46,26 @@
|
|||
.viewer-card__background {
|
||||
background-position: top;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
* @function getLastBroadcast
|
||||
|
|
|
@ -15,6 +15,26 @@
|
|||
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 {
|
||||
display: inline;
|
||||
}
|
||||
|
@ -64,6 +84,19 @@
|
|||
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-clicked.loading,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue