1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
* 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:
SirStendec 2023-03-30 14:54:33 -04:00
parent 53814e7024
commit 880e80388a
25 changed files with 1382 additions and 54 deletions

View file

@ -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",

View file

@ -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": {

View file

@ -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,

View file

@ -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;

View file

@ -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() {

View 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>

View 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>

View 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>

View 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>

View file

@ -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;

View file

@ -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>

View file

@ -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

View file

@ -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">

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 = [

View file

@ -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) {

View file

@ -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;
}

View 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>

View file

@ -0,0 +1,11 @@
mutation FFZ_FollowUser($input: FollowUserInput!) {
followUser(input: $input) {
follow {
disableNotifications
followedAt
}
error {
code
}
}
}

View file

@ -0,0 +1,8 @@
mutation FFZ_UnfollowUser($input: UnfollowUserInput!) {
unfollowUser(input: $input) {
follow {
disableNotifications
followedAt
}
}
}

View file

@ -0,0 +1,11 @@
query FFZ_UserFollowed($id: ID, $login: String) {
user(id: $id, login: $login) {
id
self {
follower {
disableNotifications
followedAt
}
}
}
}

View file

@ -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

View file

@ -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,