1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-12 17:10:54 +00:00

4.0.0-rc1.5

Add an option to hide the mouse over the player. This doesn't work well in Chrome. Oh well, we tried.
Add an option to not automatically join raids for that comfy falling asleep experience.
Fix in-line actions not rendering when they should.
Fix detokenizeMessage for changes to Twitch's message format.
Don't try to preview the `create` clip URL.
Start using functional components where possible for performance.
Stop logging GraphQL errors to save our Sentry quota.

Begin implementing chat rendering on top of Vue. For now, we've got functional components for every type of chat token. We've got a lot of work ahead of us. This will eventually be used for mod card history, chat panes, and maybe even pinned rooms.

Add an event to clean orphan tooltips. Useful for when we click something we know will change DOM elements with active tooltips, like a Close button for example.
This commit is contained in:
SirStendec 2018-05-10 19:56:39 -04:00
parent 9ef7c2aee3
commit 9dc8252df0
58 changed files with 1037 additions and 392 deletions

View file

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

View file

@ -0,0 +1,6 @@
<template functional>
<figure
:class="`${props.data.icon||'ffz-i-zreknarf'}`"
:style="{color:props.color}"
/>
</template>

View file

@ -0,0 +1,5 @@
<template functional>
<figure class="mod-icon__image">
<img :src="props.data.image">
</figure>
</template>

View file

@ -0,0 +1,5 @@
<template functional>
<span :style="{color: props.color}">
{{ props.data.text }}
</span>
</template>

View file

@ -181,10 +181,10 @@ export default class Actions extends Module {
def = this.renderers[ap.type];
if ( ! def || disp.disabled ||
(disp.mod_icons != null && disp.mod_icons !== mod_icons) ||
(disp.mod != null && disp.mod !== (current_user ? current_user.moderator : false)) ||
(disp.staff != null && disp.staff !== (current_user ? current_user.staff : false)) ||
(disp.deleted != null && disp.deleted !== msg.deleted) )
(disp.mod_icons != null && disp.mod_icons !== !!mod_icons) ||
(disp.mod != null && disp.mod !== (current_user ? !!current_user.moderator : false)) ||
(disp.staff != null && disp.staff !== (current_user ? !!current_user.staff : false)) ||
(disp.deleted != null && disp.deleted !== !!msg.deleted) )
continue;
const has_color = def.colored && ap.color,

View file

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

View file

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

View file

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

View file

@ -12,9 +12,10 @@ export const text = {
title_i18n: 'setting.actions.appearance.text',
colored: true,
preview: () => import(/* webpackChunkName: 'main-menu' */ './preview-text.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-text.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-text.vue'),
component: () => import(/* webpackChunkName: 'main-menu' */ './components/preview-text.vue'),
render(data, createElement, color) {
return <span style={{color}}>{data.text}</span>;
}
@ -30,8 +31,7 @@ export const icon = {
title_i18n: 'setting.actions.appearance.icon',
colored: true,
preview: () => import(/* webpackChunkName: 'main-menu' */ './preview-icon.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-icon.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-icon.vue'),
load(data) {
if ( data.icon && data.icon.startsWith('ffz-fa') )
@ -40,6 +40,8 @@ export const icon = {
return true;
},
component: () => import(/* webpackChunkName: 'main-menu' */ './components/preview-icon.vue'),
render(data, createElement, color) {
return <figure style={{color}} class={`${data.icon||'ffz-i-zreknarf'}`} />;
}
@ -54,9 +56,9 @@ export const image = {
title: 'Image',
title_i18n: 'setting.actions.appearance.image',
preview: () => import(/* webpackChunkName: 'main-menu' */ './preview-image.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-image.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-image.vue'),
component: () => import(/* webpackChunkName: 'main-menu' */ './components/preview-image.vue'),
render(data, createElement) {
return <figure class="mod-icon__image"><img src={data.image} /></figure>;
}

View file

@ -24,7 +24,7 @@ export const open_url = {
url: 'https://link.example/{{user.login}}'
},
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-url.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-url.vue'),
title: 'Open URL',
description: '%{options.url}',
@ -71,7 +71,7 @@ export const chat = {
title: 'Chat Command',
description: '%{options.command}',
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-chat.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-chat.vue'),
process(data) {
return transformPhrase(
@ -121,7 +121,7 @@ export const ban = {
}
}],
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-ban.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-ban.vue'),
title: 'Ban User',
@ -152,7 +152,7 @@ export const timeout = {
duration: 600
},
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-timeout.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-timeout.vue'),
title: 'Timeout User',
description: '%{options.duration} second%{options.duration|en_plural}',

View file

@ -0,0 +1,11 @@
<template functional>
<span
:data-prefix="props.token.prefix"
:data-amount="props.token.amount"
:data-tier="props.token.tier"
:data-individuals="JSON.stringify(props.token.individuals || null)"
:alt="props.token.text"
class="ffz-cheer ffz-tooltip"
data-tooltip-type="cheer"
/>
</template>

View file

@ -0,0 +1,11 @@
<template>
</template>
<script>
export default {
}
</script>

View file

@ -0,0 +1,13 @@
<template functional>
<a
:href="props.token.url"
:data-url="props.token.url"
:data-is-mail="props.token.is_mail"
class="ffz-tooltip"
data-tooltip-type="link"
rel="noopener noreferrer"
target="_blank"
>
{{ props.token.text }}
</a>
</template>

View file

@ -0,0 +1,8 @@
<template functional>
<strong
:class="{'ffz--mention-me': props.token.me}"
class="chat-line__message-mention"
>
{{ props.token.text }}
</strong>
</template>

View file

@ -0,0 +1,119 @@
<template>
<a :href="url" class="chat-card__link" target="_blank" rel="noreferrer noopener">
<div class="ffz--chat-card tw-elevation-1 tw-mg-t">
<div class="tw-c-background tw-flex tw-flex-nowrap tw-pd-05">
<div class="chat-card__preview-img tw-c-background-alt-2 tw-align-items-center tw-flex tw-flex-shrink-0 tw-justify-content-center">
<div class="tw-card-img tw-flex-shrink-0 tw-flex tw-justify-content-center">
<img
v-if="error"
class="chat-card__error-img"
src=""
data-test-selector="chat-card-error"
>
<figure
v-else
class="tw-aspect tw-aspect--16x9 tw-aspect--align-top"
>
<img
v-if="loaded && image"
:src="image"
:alt="title"
class="tw-image"
>
</figure>
</div>
</div>
<div
:class="{'ffz--two-line': desc_2}"
class="tw-overflow-hidden tw-align-items-center tw-flex"
>
<div class="tw-full-width tw-pd-l-1">
<div class="chat-card__title tw-ellipsis">
<span
:title="title"
class="tw-font-size-5"
data-test-selector="chat-card-title"
>
{{ title }}
</span>
</div>
<div class="tw-ellipsis">
<span
:title="desc_1"
class="tw-c-text-alt-2 tw-font-size-6"
data-test-selector="chat-card-description"
>
{{ desc_1 }}
</span>
</div>
<div v-if="desc_2" class="tw-ellipsis">
<span
:title="desc_2"
class="tw-c-text-alt-2 tw-font-size-6"
data-test-selector="chat-card-description"
>
{{ desc_2 }}
</span>
</div>
</div>
</div>
</div>
</div>
</a>
</template>
<script>
import {timeout} from 'utilities/object';
export default {
props: ['data', 'url'],
data() {
return {
loaded: false,
error: false,
title: null,
desc_1: null,
desc_2: null,
image: null
}
},
async mounted() {
let data;
try {
data = this.data.getData();
if ( data instanceof Promise ) {
const to_wait = this.data.timeout || 1000;
if ( to_wait )
data = await timeout(data, to_wait);
else
data = await data;
}
if ( ! data )
data = {
error: true,
title: this.t('card.error', 'An error occured.'),
desc_1: this.t('card.empty', 'No data was returned.')
}
} catch(err) {
data = {
error: true,
title: this.t('card.error', 'An error occured.'),
desc_1: String(err)
}
}
this.loaded = true;
this.error = data.error;
this.image = data.image;
this.title = data.title;
this.desc_1 = data.desc_1;
this.desc_2 = data.desc_2;
}
}
</script>

View file

@ -27,7 +27,7 @@ export const Clips = {
const match = CLIP_URL.exec(token.url),
apollo = this.resolve('site.apollo');
if ( ! apollo || ! match )
if ( ! apollo || ! match || match[1] === 'create' )
return;
return {

View file

@ -16,13 +16,6 @@ export default class Room {
constructor(manager, id, login) {
this._destroy_timer = null;
this.manager = manager;
this._id = id;
this.login = login;
if ( id )
this.manager.room_ids[id] = this;
this.refs = new Set;
this.style = new ManagedStyle(`room--${login}`);
@ -30,6 +23,13 @@ export default class Room {
this.users = {};
this.user_ids = {};
this.manager = manager;
this._id = id;
this.login = login;
if ( id )
this.manager.room_ids[id] = this;
this.manager.emit(':room-add', this);
this.load_data();
}

View file

@ -25,6 +25,8 @@ export const Links = {
type: 'link',
priority: 50,
component: () => import(/* webpackChunkName: 'vue-chat' */ './components/chat-link.vue'),
render(token, createElement) {
return (<a
class="ffz-tooltip"
@ -193,6 +195,8 @@ export const Mentions = {
type: 'mention',
priority: 0,
component: () => import(/* webpackChunkName: 'vue-chat' */ './components/chat-mention.vue'),
render(token, createElement) {
return (<strong class={`chat-line__message-mention${token.me ? ' ffz--mention-me' : ''}`}>
{token.text}
@ -264,6 +268,8 @@ export const CheerEmotes = {
type: 'cheer',
priority: 40,
component: () => import(/* webpackChunkName: 'vue-chat' */ './components/chat-cheer.vue'),
render(token, createElement) {
return (<span
class="ffz-cheer ffz-tooltip"
@ -432,10 +438,50 @@ export const CheerEmotes = {
// Addon Emotes
// ============================================================================
const render_emote = (token, createElement) => {
const mods = token.modifiers || [], ml = mods.length,
emote = createElement('img', {
class: `${EMOTE_CLASS} ffz-tooltip${token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`,
attrs: {
src: token.src,
srcSet: token.srcSet,
alt: token.text,
'data-tooltip-type': 'emote',
'data-provider': token.provider,
'data-id': token.id,
'data-set': token.set,
'data-code': token.code,
'data-variant': token.variant,
'data-modifiers': ml ? mods.map(x => x.id).join(' ') : null,
'data-modifier-info': ml ? JSON.stringify(mods.map(x => [x.set, x.id])) : null
}
});
if ( ! ml )
return emote;
return createElement('span', {
class: `${EMOTE_CLASS} modified-emote`,
attrs: {
'data-provider': token.provider,
'data-id': token.id,
'data-set': token.set
}
}, [emote, mods.map(x => createElement('span', {key: x.text}, render_emote(x, createElement)))])
}
export const AddonEmotes = {
type: 'emote',
priority: 10,
component: {
functional: true,
render(createElement, {props}) {
return render_emote(props.token, createElement);
}
},
render(token, createElement) {
const mods = token.modifiers || [], ml = mods.length,
emote = (<img

View file

@ -11,14 +11,14 @@ export default class User {
this.manager = manager;
this.room = room;
this.emote_sets = new SourcedSet;
this.badges = new SourcedSet;
this._id = id;
this.login = login;
if ( id )
(room || manager).user_ids[id] = this;
this.emote_sets = new SourcedSet;
this.badges = new SourcedSet;
}
destroy() {

View file

@ -178,6 +178,18 @@
</span>
</button>
</template>
<template v-else-if="deleting">
<button class="tw-button tw-button--text" @click="$emit('remove', action)">
<span class="tw-button__text ffz-i-trash">
{{ t('setting.delete', 'Delete') }}
</span>
</button>
<button class="tw-button tw-button--text" @click="deleting = false">
<span class="tw-button__text ffz-i-cancel">
{{ t('setting.cancel', 'Cancel') }}
</span>
</button>
</template>
<template v-else>
<button
v-if="canEdit"
@ -188,7 +200,7 @@
{{ t('setting.edit', 'Edit') }}
</span>
</button>
<button class="tw-button tw-button--text" @click="$emit('remove', action)">
<button class="tw-button tw-button--text" @click="deleting = true">
<span class="tw-button__text ffz-i-trash">
{{ t('setting.delete', 'Delete') }}
</span>
@ -207,6 +219,7 @@ export default {
data() {
return {
deleting: false,
editing: false,
edit_data: null
}
@ -292,9 +305,8 @@ export default {
if ( ! this.display || ! this.display.appearance )
return;
const disp = this.display.display, out = [];
if ( ! disp )
return this.t('setting.actions.visible.always', 'always');
const disp = this.display.display || {},
out = [];
if ( disp.disable )
return this.t('setting.actions.visible.never', 'never');
@ -323,6 +335,9 @@ export default {
else if ( disp.deleted === false )
out.push(this.t('setting.actions.visible.undeleted', 'if message not deleted'));
if ( ! out.length )
return this.t('setting.actions.visible.always', 'always');
return out.join(', ');
}
},

View file

@ -8,8 +8,8 @@
class="ffz-mod-icon mod-icon tw-c-text-alt-2 tw-font-size-4"
>
<component
v-if="renderer && renderer.preview"
:is="renderer.preview"
v-if="renderer && renderer.component"
:is="renderer.component"
:data="act.appearance"
:color="color"
/>

View file

@ -404,10 +404,10 @@ export default {
if ( disp.mod != null && disp.mod !== this.is_moderator )
return false;
if ( disp.mod_icons != null && disp.mod !== this.with_mod_icons )
if ( disp.mod_icons != null && disp.mod_icons !== this.with_mod_icons )
return false;
if ( disp.staff != null && disp.mod !== this.is_staff )
if ( disp.staff != null && disp.staff !== this.is_staff )
return false;
if ( disp.deleted != null && disp.deleted !== this.is_deleted )

View file

@ -4,7 +4,7 @@
// Tooltip Handling
// ============================================================================
import {createElement} from 'utilities/dom';
import {createElement, sanitize} from 'utilities/dom';
import {has, maybe_call} from 'utilities/object';
import Tooltip from 'utilities/tooltip';
@ -31,6 +31,7 @@ export default class TooltipProvider extends Module {
]
}
this.types.text = target => sanitize(target.dataset.title);
this.types.html = target => target.dataset.title;
}
@ -56,6 +57,12 @@ export default class TooltipProvider extends Module {
}
}
});
this.on(':cleanup', this.cleanup);
}
cleanup() {
this.tips.cleanup();
}
checkDelayShow(target, tip) {
@ -89,7 +96,7 @@ export default class TooltipProvider extends Module {
}
process(target, tip) {
const type = target.dataset.tooltipType,
const type = target.dataset.tooltipType || 'text',
handler = this.types[type];
if ( ! handler )

View file

@ -156,6 +156,8 @@ Twilight.ROUTES = {
'dir-game-index': '/directory/game/:gameName',
'dir-all': '/directory/all/:filter?',
'dir-category': '/directory/:category?',
'dash': '/:userName/dashboard',
'dash-automod': '/:userName/dashboard/settings/automod',
'event': '/event/:eventName',
'popout': '/popout/:userName/chat',
'video': '/videos/:videoID',

View file

@ -16,6 +16,8 @@ export default class Channel extends Module {
this.inject('settings');
this.inject('site.fine');
this.left_raids = new Set;
this.settings.add('channel.hosting.enable', {
default: true,
ui: {
@ -26,16 +28,52 @@ export default class Channel extends Module {
changed: val => this.updateChannelHosting(val)
});
this.settings.add('channel.raids.no-autojoin', {
default: false,
ui: {
path: 'Channel > Behavior >> Raids',
title: 'Do not automatically join raids.',
component: 'setting-check-box'
}
});
this.ChannelPage = this.fine.define(
'channel-page',
n => n.handleHostingChange,
['user']
);
this.RaidController = this.fine.define(
'raid-controller',
n => n.handleLeaveRaid && n.handleJoinRaid,
['user']
);
}
onEnable() {
this.ChannelPage.on('mount', this.wrapChannelPage, this);
this.RaidController.on('mount', this.noAutoRaids, this);
this.RaidController.on('update', this.noAutoRaids, this);
this.RaidController.ready((cls, instances) => {
for(const inst of instances)
this.noAutoRaids(inst);
});
this.ChannelPage.on('update', inst => {
if ( this.settings.get('channel.hosting.enable') || ! inst.state.isHosting )
return;
// We can't do this immediately because the player state
// occasionally screws up if we do.
setTimeout(() => {
inst.ffzExpectedHost = inst.state.videoPlayerSource;
inst.ffzOldHostHandler(null);
});
});
this.ChannelPage.ready((cls, instances) => {
for(const inst of instances)
@ -44,6 +82,22 @@ export default class Channel extends Module {
}
noAutoRaids(inst) {
if ( this.settings.get('channel.raids.no-autojoin') )
setTimeout(() => {
if ( inst.state.raid && inst.hasJoinedCurrentRaid ) {
const id = inst.state.raid.id;
if ( this.left_raids.has(id) )
return;
this.log.info('Automatically leaving raid:', id);
this.left_raids.add(id);
inst.handleLeaveRaid();
}
});
}
wrapChannelPage(inst) {
if ( inst._ffz_hosting_wrapped )
return;

View file

@ -193,8 +193,8 @@ export default class EmoteMenu extends Module {
this.MenuWrapper = this.fine.wrap('ffz-emote-menu');
this.MenuSection = this.fine.wrap('ffz-menu-section');
this.MenuEmote = this.fine.wrap('ffz-menu-emote');
//this.MenuSection = this.fine.wrap('ffz-menu-section');
//this.MenuEmote = this.fine.wrap('ffz-menu-emote');
}
onEnable() {
@ -281,62 +281,48 @@ export default class EmoteMenu extends Module {
React = this.web_munch.getModule('react'),
createElement = React && React.createElement;
this.MenuEmote = class FFZMenuEmote extends React.Component {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
this.MenuEmote = function({source, data, lock, locked, all_locked, onClickEmote}) {
const handle_click = e => {
if ( ! t.emotes.handleClick(e) )
onClickEmote(data.name);
};
handleClick(event) {
if ( ! t.emotes.handleClick(event) )
this.props.onClickEmote(this.props.data.name);
}
const sellout = lock ?
all_locked ?
t.i18n.t('emote-menu.emote-sub', 'Subscribe for %{price} to unlock this emote.', lock) :
t.i18n.t('emote-menu.emote-up', 'Upgrade your sub to %{price} to unlock this emote.', lock)
: null;
render() {
const data = this.props.data,
lock = this.props.lock,
locked = this.props.locked,
favorite = data.favorite,
sellout = lock ?
this.props.all_locked ?
t.i18n.t('emote-menu.emote-sub', 'Subscribe for %{price} to unlock this emote.', lock) :
t.i18n.t('emote-menu.emote-up', 'Upgrade your sub to %{price} to unlock this emote.', lock)
: null;
return (<button
class={`ffz-tooltip emote-picker__emote-link${locked ? ' locked' : ''}`}
data-tooltip-type="emote"
data-provider={data.provider}
data-id={data.id}
data-set={data.set_id}
data-code={data.code}
data-variant={data.variant}
data-no-source={this.props.source}
data-name={data.name}
aria-label={data.name}
data-locked={data.locked}
data-sellout={sellout}
onClick={!data.locked && this.handleClick}
>
<figure class="emote-picker__emote-figure">
<img
class={`emote-picker__emote-image${data.emoji ? ' ffz-emoji' : ''}`}
src={data.src}
srcSet={data.srcSet}
alt={data.name}
height={data.height ? `${data.height}px` : null}
width={data.width ? `${data.width}px` : null}
/>
</figure>
{favorite && (<figure class="ffz--favorite ffz-i-star" />)}
{locked && (<figure class="ffz-i-lock" />)}
</button>);
}
return (<button
class={`ffz-tooltip emote-picker__emote-link${locked ? ' locked' : ''}`}
data-tooltip-type="emote"
data-provider={data.provider}
data-id={data.id}
data-set={data.set_id}
data-code={data.code}
data-variant={data.variant}
data-no-source={source}
data-name={data.name}
aria-label={data.name}
data-locked={data.locked}
data-sellout={sellout}
onClick={!data.locked && handle_click}
>
<figure class="emote-picker__emote-figure">
<img
class={`emote-picker__emote-image${data.emoji ? ' ffz-emoji' : ''}`}
src={data.src}
srcSet={data.srcSet}
alt={data.name}
height={data.height ? `${data.height}px` : null}
width={data.width ? `${data.width}px` : null}
/>
</figure>
{data.favorite && (<figure class="ffz--favorite ffz-i-star" />)}
{locked && (<figure class="ffz-i-lock" />)}
</button>)
}
this.fine.wrap('ffz-menu-emote', this.MenuEmote);
this.MenuSection = class FFZMenuSection extends React.Component {
constructor(props) {

View file

@ -4,7 +4,7 @@
// Chat Hooks
// ============================================================================
import {ColorAdjuster, Color} from 'utilities/color';
import {ColorAdjuster} from 'utilities/color';
import {setChildren} from 'utilities/dom';
import {has, split_chars} from 'utilities/object';
@ -120,7 +120,7 @@ export default class ChatHook extends Module {
this.inject(SettingsMenu);
this.inject(EmoteMenu);
this.inject(TabCompletion);
this.inject(ModCards);
@ -334,12 +334,11 @@ export default class ChatHook extends Module {
grabTypes() {
const ct = this.web_munch.getModule('chat-types');
if ( ct ) {
this.automod_types = ct && ct.a || AUTOMOD_TYPES;
this.chat_types = ct && ct.b || CHAT_TYPES;
this.message_types = ct && ct.c || MESSAGE_TYPES;
this.mod_types = ct && ct.e || MOD_TYPES;
}
this.automod_types = ct && ct.a || AUTOMOD_TYPES;
this.chat_types = ct && ct.b || CHAT_TYPES;
this.message_types = ct && ct.c || MESSAGE_TYPES;
this.mod_types = ct && ct.e || MOD_TYPES;
}

View file

@ -28,6 +28,8 @@ export default class ChatLine extends Module {
this.inject('site.apollo');
this.inject(RichContent);
this.inject('site.chat.mod_cards');
this.inject('chat.actions');
this.ChatLine = this.fine.define(
@ -127,6 +129,9 @@ export default class ChatLine extends Module {
const tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, u, r),
rich_content = FFZRichContent && t.chat.pluckRichContent(tokens, msg);
if ( ! this.ffz_user_click_handler )
this.ffz_user_click_handler = event => event.ctrlKey ? this.usernameClickHandler(event) : t.mod_cards.openCard(r, user, event);
let cls = 'chat-line__message',
out = (tokens.length || ! msg.ffz_type) ? [
this.props.showTimestamps && e('span', {
@ -139,7 +144,7 @@ export default class ChatLine extends Module {
e('a', {
className: 'chat-author__display-name notranslate',
style: { color },
onClick: t.parent.mod_cards.openCustomModCard.bind(t.parent.mod_cards, this, user)
onClick: this.usernameClickHandler, // this.ffz_user_click_handler
}, [
user.userDisplayName,
user.isIntl && e('span', {
@ -274,46 +279,47 @@ export function detokenizeMessage(msg) {
for(let i=0; i < l; i++) {
const part = parts[i],
type = part.type,
content = part.content;
if ( type === 0 )
if ( ! content )
continue;
if ( typeof content === 'string' )
ret = content;
else if ( type === 1 )
else if ( content.recipient )
ret = `@${content.recipient}`;
else if ( type === 2 )
ret = content.displayText;
else if ( content.url )
ret = content.url;
else if ( type === 3 ) {
if ( content.cheerAmount ) {
ret = `${content.alt}${content.cheerAmount}`;
else if ( content.cheerAmount )
ret = `${content.alt}${content.cheerAmount}`;
} else {
const url = (content.images.themed ? content.images.dark : content.images.sources)['1x'],
match = /\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url),
id = match && match[1];
else if ( content.images ) {
const url = (content.images.themed ? content.images.dark : content.images.sources),
match = url && /\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url['1x']),
id = match && match[1];
ret = content.alt;
ret = content.alt;
if ( id ) {
const em = emotes[id] = emotes[id] || [],
offset = last_type > 0 ? 1 : 0;
em.push({startIndex: idx + offset, endIndex: idx + ret.length - 1});
}
if ( id ) {
const em = emotes[id] = emotes[id] || [],
offset = last_type > 0 ? 1 : 0;
em.push({startIndex: idx + offset, endIndex: idx + ret.length - 1});
}
if ( last_type > 0 )
ret = ` ${ret}`;
} else if ( type === 4 )
ret = `https://clips.twitch.tv/${content.slug}`;
} else
continue;
if ( ret ) {
idx += ret.length;
last_type = type;
out.push(ret);
last_type = part.type;
out.push(ret)
}
}

View file

@ -0,0 +1,265 @@
<template>
<div
:style="{zIndex: z}"
class="ffz-mod-card tw-elevation-3 tw-c-background-alt tw-c-text tw-border tw-flex tw-flex-nowrap tw-flex-column"
tabindex="0"
@focusin="onFocus"
@keyup.esc="close"
>
<header
:style="loaded && `background-image: url('${user.bannerImageURL}');`"
class="tw-full-width tw-align-items-center tw-flex tw-flex-nowrap tw-relative"
>
<div class="tw-full-width tw-align-items-center tw-flex tw-flex-nowrap tw-pd-1 ffz--background-dimmer">
<figure class="tw-avatar tw-avatar--size-50">
<div v-if="loaded" class="tw-overflow-hidden ">
<img
:src="user.profileImageURL"
class="tw-image"
>
</div>
</figure>
<div class="tw-ellipsis tw-inline-block">
<div class="tw-align-items-center tw-mg-l-1 ffz--info-lines">
<h4>
<a :href="`/${login}`" class="tw-link tw-link--hover-underline-none tw-link--inherit" target="_blank">
{{ displayName }}
</a>
</h4>
<h5
v-if="displayName && displayName.toLowerCase() !== login"
>
<a :href="`/${login}`" class="tw-link tw-link--hover-underline-none tw-link--inherit" target="_blank">
{{ login }}
</a>
</h5>
<div v-if="loaded">
<span
:data-title="t('viewer-card.views', 'Views')"
class="ffz-tooltip tw-mg-r-05 ffz-i-views"
>
{{ t(null, '%{views|number}', {views: user.profileViewCount}) }}
</span>
<span
:data-title="t('viewer-card.followers', 'Followers')"
class="ffz-tooltip tw-mg-r-05 ffz-i-heart"
>
{{ t(null, '%{followers|number}', {followers: user.followers.totalCount}) }}
</span>
<span
data-title="rawUserAge"
class="ffz-tooltip ffz-i-clock"
>
{ userAge }
</span>
</div>
</div>
</div>
<div class="tw-flex-grow-1 tw-pd-x-2" />
<button
:data-title="t('viewer-card.close', 'Close')"
class="ffz-tooltip tw-button-icon tw-absolute tw-right-0 tw-top-0 tw-mg-t-05 tw-mg-r-05"
@click="close"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-cancel" />
</span>
</button>
<button
v-show="! pinned"
:data-title="t('viewer-card.pin', 'Pin')"
class="ffz-tooltip tw-button-icon tw-absolute tw-right-0 tw-bottom-0 tw-mg-b-05 tw-mg-r-05"
@click="pin"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-pin" />
</span>
</button>
</div>
</header>
<error-tab v-if="errored" />
<template v-else-if="loaded">
<section class="tw-c-background">
<div class="mod-cards__tabs-container tw-border-t">
<div
v-for="(data, key) in tabs"
:key="key"
:id="`mod-cards__${key}`"
:class="{active: active_tab === key}"
class="mod-cards__tab tw-pd-x-1"
@click="active_tab = key"
>
<span>{{ data.label }}</span>
</div>
</div>
</section>
<keep-alive>
<component
:is="current_tab.component"
:tab="current_tab"
:channel="channel"
:user="user"
:self="self"
:getFFZ="getFFZ"
@close="close"
/>
</keep-alive>
</template>
<loading-tab v-else />
</div>
</template>
<script>
import LoadingTab from './components/loading-tab.vue';
import ErrorTab from './components/error-tab.vue';
import displace from 'displacejs';
export default {
components: {
'error-tab': ErrorTab,
'loading-tab': LoadingTab
},
props: ['tabs', 'room', 'raw_user', 'pos_x', 'pos_y', 'data', 'getZ', 'getFFZ'],
data() {
return {
active_tab: Object.keys(this.tabs)[0],
z: this.getZ(),
loaded: false,
errored: false,
pinned: false,
user: null,
channel: null,
self: null
}
},
computed: {
login() {
if ( this.loaded )
return this.user.login;
return this.raw_user.login;
},
displayName() {
if ( this.loaded )
return this.user.displayName;
return this.raw_user.displayName || this.raw_user.login;
},
current_tab() {
return this.tabs[this.active_tab];
}
},
beforeMount() {
this.data.then(data => {
this.loaded = true;
this.user = data.data.targetUser;
this.channel = data.data.channelUser;
this.self = data.data.currentUser;
}).catch(err => {
console.error(err); // eslint-disable-line no-console
this.errored = true;
});
},
mounted() {
this._on_resize = this.handleResize.bind(this);
window.addEventListener('resize', this._on_resize);
this.createDrag();
},
beforeDestroy() {
this.destroyDrag();
if ( this._on_resize ) {
window.removeEventListener('resize', this._on_resize);
this._on_resize = null;
}
},
methods: {
constrain() {
const el = this.$el;
let parent = el.parentElement,
moved = false;
if ( ! parent )
parent = document.body;
const box = el.getBoundingClientRect(),
pbox = parent.getBoundingClientRect();
if ( box.top < pbox.top ) {
el.style.top = `${el.offsetTop + (pbox.top - box.top)}px`;
moved = true;
} else if ( box.bottom > pbox.bottom ) {
el.style.top = `${el.offsetTop - (box.bottom - pbox.bottom)}px`;
moved = true;
}
if ( box.left < pbox.left ) {
el.style.left = `${el.offsetLeft + (pbox.left - box.left)}px`;
moved = true;
} else if ( box.right > pbox.right ) {
el.style.left = `${el.offsetLeft - (box.right - pbox.right)}px`;
moved = true;
}
if ( moved && this.displace )
this.displace.reinit();
},
pin() {
this.pinned = true;
this.$emit('pin');
},
close() {
this.$emit('close');
},
createDrag() {
this.$nextTick(() => {
this.displace = displace(this.$el, {
handle: this.$el.querySelector('header'),
highlightInputs: true,
constrain: true
});
})
},
destroyDrag() {
if ( this.displace ) {
this.displace.destroy();
this.displace = null;
}
},
handleResize() {
if ( this.displace )
this.displace.reinit()
},
onFocus() {
console.log('got focus!');
this.z = this.getZ();
},
focus() {
this.$el.focus();
}
}
}
</script>

View file

@ -0,0 +1,11 @@
<template functional>
<div class="tw-align-center tw-pd-1">
<div class="tw-mg-t-1 tw-mg-b-2">
<img
src="//cdn.frankerfacez.com/emoticon/26608/2"
srcSet="//cdn.frankerfacez.com/emoticon/26608/2 1x, //cdn.frankerfacez.com/emoticon/26608/4 2x"
>
</div>
{{ parent.t('viewer-card.error', 'There was an error loading data for this user.') }}
</div>
</template>

View file

@ -0,0 +1,5 @@
<template functional>
<div class="tw-align-center tw-pd-1">
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
</div>
</template>

View file

@ -1,6 +1,6 @@
<template>
<section class="tw-background-c tw-relative">
<div class="tw-c-background tw-full-width tw-flex tw-flex-row tw-pd-r-05 tw-pd-l-1 tw-pd-y-1">
<div class="tw-background-c tw-full-width tw-flex tw-flex-row tw-pd-r-05 tw-pd-l-1 tw-pd-y-1">
<div class="tw-mg-r-05">
<div class="tw-inline-block">
<button class="tw-button">

View file

@ -0,0 +1,50 @@
<template>
<div>
<error-tab v-if="errored" />
<loading-tab v-else-if="loading" />
<template v-else>
{{ JSON.stringify(data) }}
</template>
</div>
</template>
<script>
import LoadingTab from './loading-tab.vue';
import ErrorTab from './error-tab.vue';
export default {
components: {
'loading-tab': LoadingTab,
'error-tab': ErrorTab
},
props: ['tab', 'channel', 'user', 'self', 'getFFZ'],
data() {
return {
loading: true,
errored: false,
data: null
}
},
mounted() {
const socket = this.getFFZ().resolve('socket');
if ( ! socket ) {
this.errored = true;
return;
}
socket.call('get_name_history', this.user.login).then(data => {
this.loading = false;
this.data = data;
}).catch(err => {
console.error(err);
this.errored = true;
})
}
}
</script>

View file

@ -0,0 +1,13 @@
<template>
<div>
</div>
</template>
<script>
export default {
}
</script>

View file

@ -1,25 +1,50 @@
query($userLogin: String) {
user(login: $userLogin) {
bannerImageURL
displayName
query FFZ_ViewerCard($targetLogin: String!, $channelID: ID!) {
targetUser: user(login: $targetLogin) {
id
login
profileImageURL(width: 50)
displayName
bannerImageURL
profileImageURL(width: 70)
createdAt
profileViewCount
followers {
totalCount
}
profileViewCount
self {
friendship {
... on FriendEdge {
node {
displayName
id
login
}
}
...friendButtonFragment
}
channelUser: user(id: $channelID) {
id
login
displayName
subscriptionProducts {
id
price
url
emoteSetID
emotes {
id
}
}
self {
isModerator
}
}
currentUser {
id
login
roles {
isSiteAdmin
isStaff
isGlobalMod
}
}
}
fragment friendButtonFragment on User {
id
self {
friendship {
__typename
}
}
}

View file

@ -6,6 +6,7 @@
import Module from 'utilities/module';
import {has} from 'utilities/object';
import {createElement} from 'utilities/dom';
import GET_USER_INFO from './get_user_info.gql';
@ -14,104 +15,162 @@ export default class ModCards extends Module {
constructor(...args) {
super(...args);
this.inject('site.apollo');
this.inject('i18n');
this.inject('settings');
this.inject('site.apollo');
this.lastZIndex = 9001;
this.open_mod_cards = {};
this.tabs = {};
this.last_z = 9000;
this.open_cards = {};
this.unpinned_card = null;
this.addTab('main', {
visible: () => true,
visible: true,
label: 'Main',
pill: 0,
pill: 3,
data: (user, room) => ({
}),
component: () => import('./components/main.vue')
component: () => import(/* webpackChunkName: 'viewer-cards' */ './components/main.vue')
});
this.addTab('stats', {
label: 'Stats',
component: () => import(/* webpackChunkName: 'viewer-cards' */ './components/stats.vue')
});
this.addTab('name-history', {
label: 'Name History',
component: () => import(/* webpackChunkName: 'viewer-cards' */ './components/name-history.vue')
});
}
addTab(key, data) {
if (this.tabs[key]) return;
if ( this.tabs[key] )
return this.log.warn(`Attempted to re-define known tab "${key}"`);
this.tabs[key] = data;
}
async openCustomModCard(t, user, e) {
// Old mod-card
// t.usernameClickHandler(e);
const posX = Math.min(window.innerWidth - 300, e.clientX),
posY = Math.min(window.innerHeight - 300, e.clientY),
room = {
id: t.props.channelID,
login: t.props.message.roomLogin
},
currentUser = {
isModerator: t.props.isCurrentUserModerator,
isStaff: t.props.isCurrentUserStaff
onEnable() {
this.vue = this.resolve('vue');
}
async loadVue() {
if ( this._vue_loaded )
return;
const [, card_component] = await Promise.all([
this.vue.enable(),
import(/* webpackChunkName: 'viewer-cards' */ './card.vue')
]);
this.vue.component('viewer-card', card_component.default);
this._vue_loaded = true;
}
async openCard(room, user, event) {
if ( user.userLogin && ! user.login )
user = {
login: user.userLogin,
id: user.userID,
displayName: user.userDisplayName,
};
if (this.open_mod_cards[user.userLogin]) {
this.open_mod_cards[user.userLogin].style.zIndex = ++this.lastZIndex;
const old_card = this.open_cards[user.login];
if ( old_card ) {
old_card.$el.style.zIndex = ++this.last_z;
old_card.focus();
return;
}
const vue = this.resolve('vue'),
_mod_card_vue = import(/* webpackChunkName: "mod-card" */ './mod-card.vue'),
_user_info = this.apollo.client.query({
query: GET_USER_INFO,
variables: {
userLogin: user.userLogin
}
});
let pos_x = event ? event.clientX : window.innerWidth / 2,
pos_y = event ? event.clientY + 15 : window.innerHeight / 2;
const [, mod_card_vue, user_info] = await Promise.all([vue.enable(), _mod_card_vue, _user_info]);
if ( this.unpinned_card ) {
const card = this.unpinned_card;
vue.component('mod-card', mod_card_vue.default);
pos_x = card.$el.offsetLeft;
pos_y = card.$el.offsetTop;
const mod_card = this.open_mod_cards[user.userLogin] = this.buildModCard(vue, user_info.data.user, room, currentUser);
card.close();
}
const main = document.querySelector('.twilight-root>.tw-full-height');
main.appendChild(mod_card);
mod_card.style.left = `${posX}px`;
mod_card.style.top = `${posY}px`;
}
buildModCard(vue, user, room, currentUser) {
this.log.info(user);
const vueEl = new vue.Vue({
el: createElement('div'),
render: h => {
const vueModCard = h('mod-card', {
activeTab: Object.keys(this.tabs)[0],
tabs: this.tabs,
user,
room,
currentUser,
rawUserAge: this.i18n.toLocaleString(new Date(user.createdAt)),
userAge: this.i18n.toHumanTime((new Date() - new Date(user.createdAt)) / 1000),
setActiveTab: tab => vueModCard.data.activeTab = tab,
focus: el => {
el.style.zIndex = ++this.lastZIndex;
},
close: () => {
this.open_mod_cards[user.login].remove();
this.open_mod_cards[user.login] = null;
}
});
return vueModCard;
// We start this first...
const user_info = this.apollo.client.query({
query: GET_USER_INFO,
variables: {
targetLogin: user.login,
channelID: room.id
}
});
return vueEl.$el;
// But we only wait on loading Vue, since we can show a loading indicator.
await this.loadVue();
// Display the card.
this.unpinned_card = this.open_cards[user.login] = this.buildCard(
room,
user,
user_info,
pos_x,
pos_y,
);
}
buildCard(room, user, data, pos_x, pos_y) {
let child;
const component = new this.vue.Vue({
el: createElement('div'),
render: h => h('viewer-card', {
props: {
tabs: this.tabs,
room,
raw_user: user,
data,
getFFZ: () => this,
getZ: () => ++this.last_z
},
on: {
close: () => {
const el = component.$el;
el.remove();
component.$destroy();
if ( this.unpinned_card === child )
this.unpinned_card = null;
if ( this.open_cards[user.login] === child )
this.open_cards[user.login] = null;
this.emit('tooltips:cleanup');
},
pin: () => {
if ( this.unpinned_card === child )
this.unpinned_card = null;
}
}
})
});
child = component.$children[0];
const el = component.$el;
el.style.top = `${pos_y}px`;
el.style.left = `${pos_x}px`
const container = document.querySelector('.twilight-root>.tw-full-height,.twilight-minimal-root>.tw-full-height');
container.appendChild(el);
requestAnimationFrame(() => child.constrain());
return child;
}
}

View file

@ -1,139 +0,0 @@
<template>
<div
class="ffz-mod-card tw-elevation-3 tw-c-background-alt tw-c-text tw-border tw-flex tw-flex-nowrap tw-flex-column"
tabindex="-1"
@focusin="doFocus"
>
<header
:style="`background-image: url('${user.bannerImageURL}');`"
class="tw-full-width tw-align-items-center tw-flex tw-flex-nowrap tw-relative"
>
<div class="tw-full-width tw-align-items-center tw-flex tw-flex-nowrap tw-pd-1 ffz--background-dimmer">
<div class="tw-inline-block">
<figure class="tw-avatar tw-avatar--size-50">
<div class="tw-overflow-hidden ">
<img
:src="user.profileImageURL"
class="tw-image"
>
</div>
</figure>
</div>
<div class="tw-ellipsis tw-inline-block">
<div class="tw-align-items-center tw-mg-l-1 ffz--info-lines">
<h4>
<a :href="`/${user.login}`" class="tw-link tw-link--hover-underline-none tw-link--inherit" target="_blank">
{{ user.displayName }}
</a>
</h4>
<h5
v-if="user.displayName && user.displayName.toLowerCase() !== user.login.toLowerCase()"
>
<a :href="`/${user.login}`" class="tw-link tw-link--hover-underline-none tw-link--inherit" target="_blank">
{{ user.login }}
</a>
</h5>
<div>
<span class="tw-mg-r-05">
<figure class="ffz-i-info tw-inline"/>
{{ user.profileViewCount }}
</span>
<span class="tw-mg-r-05">
<figure class="ffz-i-heart tw-inline"/>
{{ user.followers.totalCount }}
</span>
<span
:data-title="rawUserAge"
data-tooltip-type="html"
class="ffz-tooltip"
>
<figure class="ffz-i-clock tw-inline"/>
{{ userAge }}
</span>
</div>
</div>
</div>
<div class="tw-flex-grow-1 tw-pd-x-2"/>
<div class="tw-inline-block">
<button class="tw-button-icon tw-absolute tw-right-0 tw-top-0 tw-mg-t-05 tw-mg-r-05" @click="close">
<span class="tw-button-icon__icon">
<figure class="ffz-i-cancel" />
</span>
</button>
<button class="tw-button-icon tw-absolute tw-right-0 tw-bottom-0 tw-mg-b-05 tw-mg-r-05" @click="close">
<span class="tw-button-icon__icon">
<figure class="ffz-i-ignore" />
</span>
</button>
</div>
</div>
</header>
<section class="tw-background-c">
<div class="mod-cards__tabs-container tw-border-t">
<div
v-for="(data, key) in tabs"
:key="key"
:id="`mod-cards__${key}`"
:class="{active: activeTab === key}"
class="mod-cards__tab tw-pd-x-1"
@click="setActiveTab(key)"
>
<span>{{ data.label }}</span>
</div>
</div>
</section>
<component
v-for="(tab, key) in tabs"
v-if="tab.visible && activeTab === key"
:is="tab.component"
:tab="tab"
:user="user"
:room="room"
:current-user="currentUser"
:key="key"
@close="close"
/>
</div>
</template>
<script>
import displace from 'displacejs';
export default {
data() {
return this.$vnode.data;
},
mounted() {
this.createDrag();
},
beforeDestroy() {
this.destroyDrag();
},
methods: {
destroyDrag() {
if ( this.displace ) {
this.displace.destroy();
this.displace = null;
}
},
createDrag() {
this.$nextTick(() => {
this.displace = displace(this.$el, {
handle: this.$el.querySelector('header'),
highlightInputs: true,
constrain: true
});
})
},
doFocus() {
this.focus(this.$el);
}
}
}
</script>

View file

@ -0,0 +1,3 @@
.player:hover:not([data-controls="true"]):not([data-paused="true"]):not([data-ended="true"]) {
cursor: none;
}

View file

@ -184,6 +184,15 @@ export default class Player extends Module {
}
});
this.settings.add('player.hide-mouse', {
default: true,
ui: {
path: 'Channel > Player >> General',
title: "Hide mouse when controls aren't visible.",
component: 'setting-check-box'
},
changed: val => this.css_tweaks.toggle('player-hide-mouse', val)
});
}
updateHideExtensions(val) {
@ -199,6 +208,7 @@ export default class Player extends Module {
this.css_tweaks.toggle('player-ext-mouse', !this.settings.get('player.ext-interaction'));
this.css_tweaks.toggle('theatre-no-whispers', this.settings.get('player.theatre.no-whispers'));
this.css_tweaks.toggle('theatre-metadata', this.settings.get('player.theatre.metadata'));
this.css_tweaks.toggle('player-hide-mouse', this.settings.get('player.hide-mouse'));
this.css_tweaks.toggleHide('player-event-bar', this.settings.get('player.hide-event-bar'));
this.css_tweaks.toggleHide('player-rerun-bar', this.settings.get('player.hide-rerun-bar'));
this.updateHideExtensions();
@ -257,6 +267,7 @@ export default class Player extends Module {
process(inst) {
this.addResetButton(inst);
this.addEndedListener(inst);
this.addStateTags(inst);
this.addControlVisibility(inst);
this.updateVolumeScroll(inst);
}
@ -298,6 +309,17 @@ export default class Player extends Module {
inst._ffz_autoplay_handler = null;
}
if ( inst._ffz_on_state ) {
if ( p ) {
off(p, 'ended', inst._ffz_on_state);
off(p, 'pause', inst._ffz_on_state);
off(p, 'playing', inst._ffz_on_state);
off(p, 'error', inst._ffz_on_state);
}
inst._ffz_on_state = null;
}
}
@ -347,6 +369,40 @@ export default class Player extends Module {
}
addStateTags(inst) {
const p = inst.player;
if ( ! p )
return;
if ( inst._ffz_on_state ) {
off(p, 'ended', inst._ffz_on_state);
off(p, 'pause', inst._ffz_on_state);
off(p, 'playing', inst._ffz_on_state);
off(p, 'error', inst._ffz_on_state);
}
const f = inst._ffz_on_state = () => this.updateStateTags(inst);
on(p, 'ended', f);
on(p, 'pause', f);
on(p, 'playing', f);
on(p, 'error', f);
f();
}
updateStateTags(inst) { // eslint-disable-line class-methods-use-this
const p = inst.playerRef,
player = inst.player;
if ( ! p || ! player )
return;
p.dataset.ended = player.ended;
p.dataset.paused = player.paused;
}
disableAutoplay(inst) {
const p = inst.player;
if ( ! p )

View file

@ -111,10 +111,6 @@
cursor: pointer;
}
.loading {
animation: ffz-rotateplane 1.2s infinite linear;
}
.ffz--menu-badge {
width: 1.8rem; height: 1.8rem;
}

View file

@ -0,0 +1,11 @@
<template functional>
<div class="tw-tooltip-wrapper">
<slot />
<div
:class="`tw-tooltip--align-${props.align||'center'} tw-tooltip--${props.above ? 'up' : 'down'}`"
class="tw-tooltip"
>
<slot name="tooltip" />
</div>
</div>
</template>

View file

@ -125,7 +125,8 @@ export default class Apollo extends Module {
try {
out.subscribe({
next: result => {
if ( result.errors ) {
// Logging GQL errors is garbage. Don't do it.
/*if ( result.errors ) {
const name = operation.operationName;
if ( name && (name.includes('FFZ') || has(this.modifiers, name) || has(this.post_modifiers, name)) ) {
for(const err of result.errors) {
@ -142,7 +143,7 @@ export default class Apollo extends Module {
});
}
}
}
}*/
this.log.crumb({
level: 'info',

View file

@ -127,6 +127,18 @@ export class Tooltip {
}
cleanup() {
for(const el of this.elements) {
const tip = el[this._accessor];
if ( document.body.contains(el) )
continue;
if ( tip && tip.visible )
this.hide(tip);
}
}
_enter(target) {
let tip = target[this._accessor];
if ( ! tip )