mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-10-19 17:32:00 +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:
parent
9ef7c2aee3
commit
9dc8252df0
58 changed files with 1037 additions and 392 deletions
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
265
src/sites/twitch-twilight/modules/chat/mod_cards/card.vue
Normal file
265
src/sites/twitch-twilight/modules/chat/mod_cards/card.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,13 @@
|
|||
<template>
|
||||
<div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
|
||||
}
|
||||
|
||||
</script>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
|||
.player:hover:not([data-controls="true"]):not([data-paused="true"]):not([data-ended="true"]) {
|
||||
cursor: none;
|
||||
}
|
|
@ -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 )
|
||||
|
|
|
@ -111,10 +111,6 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loading {
|
||||
animation: ffz-rotateplane 1.2s infinite linear;
|
||||
}
|
||||
|
||||
.ffz--menu-badge {
|
||||
width: 1.8rem; height: 1.8rem;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue