1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-28 05:15: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

@ -1,3 +1,17 @@
<div class="list-header">4.0.0-rc1.5<span>@eb1433f63b4667bf9010</span> <time datetime="2018-05-10">(2018-05-10)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Added: Option to hide the mouse cursor over the player. This does not work consistently in some web browser due to browser policies preventing the cursor from being hidden in certain situations.</li>
<li>Added: Option to not automatically join raids.</li>
<li>Fixed: In-line Actions not rendering when Is Moderator is set to False and the user is not a moderator.</li>
<li>Fixed: In-line Actions not appearing correctly in the editor preview line.</li>
<li>Fixed: Adapt to changes Twitch has made to pre-tokenized chat message data.</li>
<li>Fixed: Do not try showing a rich clip preview for the bad link <code>https://clips.twitch.tv/create</code> that is commonly erroneously posted in chat.</li>
<li>Fixed: Tooltips not closing in some situations.</li>
<li>Fixed: Chat message types sometimes not being detected, causing messages with <code>/me</code> to not appear correctly.</li>
<li>Changed: Improve emote menu performance using functional components.</li>
<li>Changed: Stop logging GraphQL errors. Twitch's GraphQL endpoint breaks too often for this to be useful.</li>
</ul>
<div class="list-header">4.0.0-rc1.4<span>@2009dc29d6bd5e122bd6</span> <time datetime="2018-04-29">(2018-04-29)</time></div> <div class="list-header">4.0.0-rc1.4<span>@2009dc29d6bd5e122bd6</span> <time datetime="2018-04-29">(2018-04-29)</time></div>
<ul class="chat-menu-content menu-side-padding"> <ul class="chat-menu-content menu-side-padding">
<li>Fixed: Issue with mod actions not working properly on AutoMod messages due to a missing channel mapping.</li> <li>Fixed: Issue with mod actions not working properly on AutoMod messages due to a missing channel mapping.</li>

Binary file not shown.

View file

@ -86,6 +86,8 @@
<glyph glyph-name="gift" unicode="&#xe827;" d="M518 93v400h-179v-400q0-14 10-21t26-8h107q16 0 26 8t10 21z m-255 471h109l-70 90q-15 17-39 17-22 0-38-15t-15-38 15-38 38-16z m384 54q0 22-15 38t-38 15q-24 0-39-17l-69-90h108q22 0 38 16t15 38z m210-143v-179q0-7-5-12t-13-5h-53v-233q0-22-16-37t-38-16h-607q-22 0-38 16t-16 37v233h-53q-8 0-13 5t-5 12v179q0 8 5 13t13 5h245q-51 0-88 36t-37 89 37 88 88 37q60 0 94-43l72-92 71 92q34 43 94 43 52 0 88-37t37-88-37-89-88-36h245q8 0 13-5t5-13z" horiz-adv-x="857.1" /> <glyph glyph-name="gift" unicode="&#xe827;" d="M518 93v400h-179v-400q0-14 10-21t26-8h107q16 0 26 8t10 21z m-255 471h109l-70 90q-15 17-39 17-22 0-38-15t-15-38 15-38 38-16z m384 54q0 22-15 38t-38 15q-24 0-39-17l-69-90h108q22 0 38 16t15 38z m210-143v-179q0-7-5-12t-13-5h-53v-233q0-22-16-37t-38-16h-607q-22 0-38 16t-16 37v233h-53q-8 0-13 5t-5 12v179q0 8 5 13t13 5h245q-51 0-88 36t-37 89 37 88 88 37q60 0 94-43l72-92 71 92q34 43 94 43 52 0 88-37t37-88-37-89-88-36h245q8 0 13-5t5-13z" horiz-adv-x="857.1" />
<glyph glyph-name="views" unicode="&#xe828;" d="M688 38h-375l-250 250v62 63l250 250h375l250-250v-63-62l-250-250z m-188 500c-103 0-187-84-187-188 0-103 84-187 187-187 104 0 188 84 188 187 0 104-84 188-188 188z m0-250c-35 0-62 28-62 62s27 63 62 63 63-28 63-63-28-62-63-62z" horiz-adv-x="1000" />
<glyph glyph-name="link-ext" unicode="&#xf08e;" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" /> <glyph glyph-name="link-ext" unicode="&#xf08e;" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" />
<glyph glyph-name="twitter" unicode="&#xf099;" d="M904 622q-37-54-90-93 0-8 0-23 0-73-21-145t-64-139-103-117-144-82-181-30q-151 0-276 81 19-2 43-2 126 0 224 77-59 1-105 36t-64 89q19-3 34-3 24 0 48 6-63 13-104 62t-41 115v2q38-21 82-23-37 25-59 64t-22 86q0 49 25 91 68-83 164-133t208-55q-5 21-5 41 0 75 53 127t127 53q79 0 132-57 61 12 115 44-21-64-80-100 52 6 104 28z" horiz-adv-x="928.6" /> <glyph glyph-name="twitter" unicode="&#xf099;" d="M904 622q-37-54-90-93 0-8 0-23 0-73-21-145t-64-139-103-117-144-82-181-30q-151 0-276 81 19-2 43-2 126 0 224 77-59 1-105 36t-64 89q19-3 34-3 24 0 48 6-63 13-104 62t-41 115v2q38-21 82-23-37 25-59 64t-22 86q0 49 25 91 68-83 164-133t208-55q-5 21-5 41 0 75 53 127t127 53q79 0 132-57 61 12 115 44-21-64-80-100 52 6 104 28z" horiz-adv-x="928.6" />

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -100,7 +100,7 @@ class FrankerFaceZ extends Module {
FrankerFaceZ.Logger = Logger; FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = { 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__, build: __webpack_hash__,
toString: () => toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}` `${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]; def = this.renderers[ap.type];
if ( ! def || disp.disabled || if ( ! def || disp.disabled ||
(disp.mod_icons != null && disp.mod_icons !== mod_icons) || (disp.mod_icons != null && disp.mod_icons !== !!mod_icons) ||
(disp.mod != null && disp.mod !== (current_user ? current_user.moderator : false)) || (disp.mod != null && disp.mod !== (current_user ? !!current_user.moderator : false)) ||
(disp.staff != null && disp.staff !== (current_user ? current_user.staff : false)) || (disp.staff != null && disp.staff !== (current_user ? !!current_user.staff : false)) ||
(disp.deleted != null && disp.deleted !== msg.deleted) ) (disp.deleted != null && disp.deleted !== !!msg.deleted) )
continue; continue;
const has_color = def.colored && ap.color, 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', title_i18n: 'setting.actions.appearance.text',
colored: true, 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) { render(data, createElement, color) {
return <span style={{color}}>{data.text}</span>; return <span style={{color}}>{data.text}</span>;
} }
@ -30,8 +31,7 @@ export const icon = {
title_i18n: 'setting.actions.appearance.icon', title_i18n: 'setting.actions.appearance.icon',
colored: true, colored: true,
preview: () => import(/* webpackChunkName: 'main-menu' */ './preview-icon.vue'), editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-icon.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-icon.vue'),
load(data) { load(data) {
if ( data.icon && data.icon.startsWith('ffz-fa') ) if ( data.icon && data.icon.startsWith('ffz-fa') )
@ -40,6 +40,8 @@ export const icon = {
return true; return true;
}, },
component: () => import(/* webpackChunkName: 'main-menu' */ './components/preview-icon.vue'),
render(data, createElement, color) { render(data, createElement, color) {
return <figure style={{color}} class={`${data.icon||'ffz-i-zreknarf'}`} />; return <figure style={{color}} class={`${data.icon||'ffz-i-zreknarf'}`} />;
} }
@ -54,9 +56,9 @@ export const image = {
title: 'Image', title: 'Image',
title_i18n: 'setting.actions.appearance.image', title_i18n: 'setting.actions.appearance.image',
preview: () => import(/* webpackChunkName: 'main-menu' */ './preview-image.vue'), editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-image.vue'),
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-image.vue'),
component: () => import(/* webpackChunkName: 'main-menu' */ './components/preview-image.vue'),
render(data, createElement) { render(data, createElement) {
return <figure class="mod-icon__image"><img src={data.image} /></figure>; 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}}' 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', title: 'Open URL',
description: '%{options.url}', description: '%{options.url}',
@ -71,7 +71,7 @@ export const chat = {
title: 'Chat Command', title: 'Chat Command',
description: '%{options.command}', description: '%{options.command}',
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-chat.vue'), editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-chat.vue'),
process(data) { process(data) {
return transformPhrase( 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', title: 'Ban User',
@ -152,7 +152,7 @@ export const timeout = {
duration: 600 duration: 600
}, },
editor: () => import(/* webpackChunkName: 'main-menu' */ './edit-timeout.vue'), editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-timeout.vue'),
title: 'Timeout User', title: 'Timeout User',
description: '%{options.duration} second%{options.duration|en_plural}', 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), const match = CLIP_URL.exec(token.url),
apollo = this.resolve('site.apollo'); apollo = this.resolve('site.apollo');
if ( ! apollo || ! match ) if ( ! apollo || ! match || match[1] === 'create' )
return; return;
return { return {

View file

@ -16,13 +16,6 @@ export default class Room {
constructor(manager, id, login) { constructor(manager, id, login) {
this._destroy_timer = null; 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.refs = new Set;
this.style = new ManagedStyle(`room--${login}`); this.style = new ManagedStyle(`room--${login}`);
@ -30,6 +23,13 @@ export default class Room {
this.users = {}; this.users = {};
this.user_ids = {}; 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.manager.emit(':room-add', this);
this.load_data(); this.load_data();
} }

View file

@ -25,6 +25,8 @@ export const Links = {
type: 'link', type: 'link',
priority: 50, priority: 50,
component: () => import(/* webpackChunkName: 'vue-chat' */ './components/chat-link.vue'),
render(token, createElement) { render(token, createElement) {
return (<a return (<a
class="ffz-tooltip" class="ffz-tooltip"
@ -193,6 +195,8 @@ export const Mentions = {
type: 'mention', type: 'mention',
priority: 0, priority: 0,
component: () => import(/* webpackChunkName: 'vue-chat' */ './components/chat-mention.vue'),
render(token, createElement) { render(token, createElement) {
return (<strong class={`chat-line__message-mention${token.me ? ' ffz--mention-me' : ''}`}> return (<strong class={`chat-line__message-mention${token.me ? ' ffz--mention-me' : ''}`}>
{token.text} {token.text}
@ -264,6 +268,8 @@ export const CheerEmotes = {
type: 'cheer', type: 'cheer',
priority: 40, priority: 40,
component: () => import(/* webpackChunkName: 'vue-chat' */ './components/chat-cheer.vue'),
render(token, createElement) { render(token, createElement) {
return (<span return (<span
class="ffz-cheer ffz-tooltip" class="ffz-cheer ffz-tooltip"
@ -432,10 +438,50 @@ export const CheerEmotes = {
// Addon Emotes // 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 = { export const AddonEmotes = {
type: 'emote', type: 'emote',
priority: 10, priority: 10,
component: {
functional: true,
render(createElement, {props}) {
return render_emote(props.token, createElement);
}
},
render(token, createElement) { render(token, createElement) {
const mods = token.modifiers || [], ml = mods.length, const mods = token.modifiers || [], ml = mods.length,
emote = (<img emote = (<img

View file

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

View file

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

View file

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

View file

@ -404,10 +404,10 @@ export default {
if ( disp.mod != null && disp.mod !== this.is_moderator ) if ( disp.mod != null && disp.mod !== this.is_moderator )
return false; 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; return false;
if ( disp.staff != null && disp.mod !== this.is_staff ) if ( disp.staff != null && disp.staff !== this.is_staff )
return false; return false;
if ( disp.deleted != null && disp.deleted !== this.is_deleted ) if ( disp.deleted != null && disp.deleted !== this.is_deleted )

View file

@ -4,7 +4,7 @@
// Tooltip Handling // Tooltip Handling
// ============================================================================ // ============================================================================
import {createElement} from 'utilities/dom'; import {createElement, sanitize} from 'utilities/dom';
import {has, maybe_call} from 'utilities/object'; import {has, maybe_call} from 'utilities/object';
import Tooltip from 'utilities/tooltip'; 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; 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) { checkDelayShow(target, tip) {
@ -89,7 +96,7 @@ export default class TooltipProvider extends Module {
} }
process(target, tip) { process(target, tip) {
const type = target.dataset.tooltipType, const type = target.dataset.tooltipType || 'text',
handler = this.types[type]; handler = this.types[type];
if ( ! handler ) if ( ! handler )

View file

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

View file

@ -16,6 +16,8 @@ export default class Channel extends Module {
this.inject('settings'); this.inject('settings');
this.inject('site.fine'); this.inject('site.fine');
this.left_raids = new Set;
this.settings.add('channel.hosting.enable', { this.settings.add('channel.hosting.enable', {
default: true, default: true,
ui: { ui: {
@ -26,16 +28,52 @@ export default class Channel extends Module {
changed: val => this.updateChannelHosting(val) 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( this.ChannelPage = this.fine.define(
'channel-page', 'channel-page',
n => n.handleHostingChange, n => n.handleHostingChange,
['user'] ['user']
); );
this.RaidController = this.fine.define(
'raid-controller',
n => n.handleLeaveRaid && n.handleJoinRaid,
['user']
);
} }
onEnable() { onEnable() {
this.ChannelPage.on('mount', this.wrapChannelPage, this); 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) => { this.ChannelPage.ready((cls, instances) => {
for(const inst of 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) { wrapChannelPage(inst) {
if ( inst._ffz_hosting_wrapped ) if ( inst._ffz_hosting_wrapped )
return; return;

View file

@ -193,8 +193,8 @@ export default class EmoteMenu extends Module {
this.MenuWrapper = this.fine.wrap('ffz-emote-menu'); this.MenuWrapper = this.fine.wrap('ffz-emote-menu');
this.MenuSection = this.fine.wrap('ffz-menu-section'); //this.MenuSection = this.fine.wrap('ffz-menu-section');
this.MenuEmote = this.fine.wrap('ffz-menu-emote'); //this.MenuEmote = this.fine.wrap('ffz-menu-emote');
} }
onEnable() { onEnable() {
@ -281,25 +281,14 @@ export default class EmoteMenu extends Module {
React = this.web_munch.getModule('react'), React = this.web_munch.getModule('react'),
createElement = React && React.createElement; createElement = React && React.createElement;
this.MenuEmote = class FFZMenuEmote extends React.Component { this.MenuEmote = function({source, data, lock, locked, all_locked, onClickEmote}) {
constructor(props) { const handle_click = e => {
super(props); if ( ! t.emotes.handleClick(e) )
this.handleClick = this.handleClick.bind(this); onClickEmote(data.name);
} };
handleClick(event) { const sellout = lock ?
if ( ! t.emotes.handleClick(event) ) all_locked ?
this.props.onClickEmote(this.props.data.name);
}
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-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) t.i18n.t('emote-menu.emote-up', 'Upgrade your sub to %{price} to unlock this emote.', lock)
: null; : null;
@ -312,12 +301,12 @@ export default class EmoteMenu extends Module {
data-set={data.set_id} data-set={data.set_id}
data-code={data.code} data-code={data.code}
data-variant={data.variant} data-variant={data.variant}
data-no-source={this.props.source} data-no-source={source}
data-name={data.name} data-name={data.name}
aria-label={data.name} aria-label={data.name}
data-locked={data.locked} data-locked={data.locked}
data-sellout={sellout} data-sellout={sellout}
onClick={!data.locked && this.handleClick} onClick={!data.locked && handle_click}
> >
<figure class="emote-picker__emote-figure"> <figure class="emote-picker__emote-figure">
<img <img
@ -329,13 +318,10 @@ export default class EmoteMenu extends Module {
width={data.width ? `${data.width}px` : null} width={data.width ? `${data.width}px` : null}
/> />
</figure> </figure>
{favorite && (<figure class="ffz--favorite ffz-i-star" />)} {data.favorite && (<figure class="ffz--favorite ffz-i-star" />)}
{locked && (<figure class="ffz-i-lock" />)} {locked && (<figure class="ffz-i-lock" />)}
</button>); </button>)
} }
}
this.fine.wrap('ffz-menu-emote', this.MenuEmote);
this.MenuSection = class FFZMenuSection extends React.Component { this.MenuSection = class FFZMenuSection extends React.Component {

View file

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

View file

@ -28,6 +28,8 @@ export default class ChatLine extends Module {
this.inject('site.apollo'); this.inject('site.apollo');
this.inject(RichContent); this.inject(RichContent);
this.inject('site.chat.mod_cards');
this.inject('chat.actions'); this.inject('chat.actions');
this.ChatLine = this.fine.define( 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), const tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, u, r),
rich_content = FFZRichContent && t.chat.pluckRichContent(tokens, msg); 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', let cls = 'chat-line__message',
out = (tokens.length || ! msg.ffz_type) ? [ out = (tokens.length || ! msg.ffz_type) ? [
this.props.showTimestamps && e('span', { this.props.showTimestamps && e('span', {
@ -139,7 +144,7 @@ export default class ChatLine extends Module {
e('a', { e('a', {
className: 'chat-author__display-name notranslate', className: 'chat-author__display-name notranslate',
style: { color }, 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.userDisplayName,
user.isIntl && e('span', { user.isIntl && e('span', {
@ -274,25 +279,26 @@ export function detokenizeMessage(msg) {
for(let i=0; i < l; i++) { for(let i=0; i < l; i++) {
const part = parts[i], const part = parts[i],
type = part.type,
content = part.content; content = part.content;
if ( type === 0 ) if ( ! content )
continue;
if ( typeof content === 'string' )
ret = content; ret = content;
else if ( type === 1 ) else if ( content.recipient )
ret = `@${content.recipient}`; ret = `@${content.recipient}`;
else if ( type === 2 ) else if ( content.url )
ret = content.displayText; ret = content.url;
else if ( type === 3 ) { else if ( content.cheerAmount )
if ( content.cheerAmount ) {
ret = `${content.alt}${content.cheerAmount}`; ret = `${content.alt}${content.cheerAmount}`;
} else { else if ( content.images ) {
const url = (content.images.themed ? content.images.dark : content.images.sources)['1x'], const url = (content.images.themed ? content.images.dark : content.images.sources),
match = /\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url), match = url && /\/emoticons\/v1\/(\d+)\/[\d.]+$/.exec(url['1x']),
id = match && match[1]; id = match && match[1];
ret = content.alt; ret = content.alt;
@ -300,20 +306,20 @@ export function detokenizeMessage(msg) {
if ( id ) { if ( id ) {
const em = emotes[id] = emotes[id] || [], const em = emotes[id] = emotes[id] || [],
offset = last_type > 0 ? 1 : 0; offset = last_type > 0 ? 1 : 0;
em.push({startIndex: idx + offset, endIndex: idx + ret.length - 1}); em.push({startIndex: idx + offset, endIndex: idx + ret.length - 1});
} }
}
if ( last_type > 0 ) if ( last_type > 0 )
ret = ` ${ret}`; ret = ` ${ret}`;
} else if ( type === 4 ) } else
ret = `https://clips.twitch.tv/${content.slug}`; continue;
if ( ret ) { if ( ret ) {
idx += ret.length; idx += ret.length;
last_type = type; last_type = part.type;
out.push(ret); 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> <template>
<section class="tw-background-c tw-relative"> <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-mg-r-05">
<div class="tw-inline-block"> <div class="tw-inline-block">
<button class="tw-button"> <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) { query FFZ_ViewerCard($targetLogin: String!, $channelID: ID!) {
user(login: $userLogin) { targetUser: user(login: $targetLogin) {
bannerImageURL
displayName
id id
login login
profileImageURL(width: 50) displayName
bannerImageURL
profileImageURL(width: 70)
createdAt createdAt
profileViewCount
followers { followers {
totalCount totalCount
} }
profileViewCount ...friendButtonFragment
self { }
friendship { channelUser: user(id: $channelID) {
... on FriendEdge {
node {
displayName
id id
login 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 Module from 'utilities/module';
import {has} from 'utilities/object';
import {createElement} from 'utilities/dom'; import {createElement} from 'utilities/dom';
import GET_USER_INFO from './get_user_info.gql'; import GET_USER_INFO from './get_user_info.gql';
@ -14,104 +15,162 @@ export default class ModCards extends Module {
constructor(...args) { constructor(...args) {
super(...args); super(...args);
this.inject('site.apollo');
this.inject('i18n'); this.inject('i18n');
this.inject('settings');
this.inject('site.apollo');
this.lastZIndex = 9001;
this.open_mod_cards = {};
this.tabs = {}; this.tabs = {};
this.last_z = 9000;
this.open_cards = {};
this.unpinned_card = null;
this.addTab('main', { this.addTab('main', {
visible: () => true, visible: true,
label: 'Main', label: 'Main',
pill: 0, pill: 3,
data: (user, room) => ({ component: () => import(/* webpackChunkName: 'viewer-cards' */ './components/main.vue')
});
}), this.addTab('stats', {
component: () => import('./components/main.vue') 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) { 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; this.tabs[key] = data;
} }
async openCustomModCard(t, user, e) {
// Old mod-card
// t.usernameClickHandler(e);
const posX = Math.min(window.innerWidth - 300, e.clientX), onEnable() {
posY = Math.min(window.innerHeight - 300, e.clientY), this.vue = this.resolve('vue');
room = { }
id: t.props.channelID,
login: t.props.message.roomLogin
}, async loadVue() {
currentUser = { if ( this._vue_loaded )
isModerator: t.props.isCurrentUserModerator, return;
isStaff: t.props.isCurrentUserStaff
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]) { const old_card = this.open_cards[user.login];
this.open_mod_cards[user.userLogin].style.zIndex = ++this.lastZIndex; if ( old_card ) {
old_card.$el.style.zIndex = ++this.last_z;
old_card.focus();
return; return;
} }
const vue = this.resolve('vue'), let pos_x = event ? event.clientX : window.innerWidth / 2,
_mod_card_vue = import(/* webpackChunkName: "mod-card" */ './mod-card.vue'), pos_y = event ? event.clientY + 15 : window.innerHeight / 2;
_user_info = this.apollo.client.query({
if ( this.unpinned_card ) {
const card = this.unpinned_card;
pos_x = card.$el.offsetLeft;
pos_y = card.$el.offsetTop;
card.close();
}
// We start this first...
const user_info = this.apollo.client.query({
query: GET_USER_INFO, query: GET_USER_INFO,
variables: { variables: {
userLogin: user.userLogin targetLogin: user.login,
channelID: room.id
} }
}); });
const [, mod_card_vue, user_info] = await Promise.all([vue.enable(), _mod_card_vue, _user_info]); // But we only wait on loading Vue, since we can show a loading indicator.
await this.loadVue();
vue.component('mod-card', mod_card_vue.default); // Display the card.
this.unpinned_card = this.open_cards[user.login] = this.buildCard(
const mod_card = this.open_mod_cards[user.userLogin] = this.buildModCard(vue, user_info.data.user, room, currentUser); room,
user,
const main = document.querySelector('.twilight-root>.tw-full-height'); user_info,
main.appendChild(mod_card); pos_x,
pos_y,
mod_card.style.left = `${posX}px`; );
mod_card.style.top = `${posY}px`;
} }
buildModCard(vue, user, room, currentUser) {
this.log.info(user); buildCard(room, user, data, pos_x, pos_y) {
const vueEl = new vue.Vue({ let child;
const component = new this.vue.Vue({
el: createElement('div'), el: createElement('div'),
render: h => { render: h => h('viewer-card', {
const vueModCard = h('mod-card', { props: {
activeTab: Object.keys(this.tabs)[0],
tabs: this.tabs, tabs: this.tabs,
user,
room, room,
currentUser, raw_user: user,
data,
rawUserAge: this.i18n.toLocaleString(new Date(user.createdAt)), getFFZ: () => this,
userAge: this.i18n.toHumanTime((new Date() - new Date(user.createdAt)) / 1000), getZ: () => ++this.last_z
setActiveTab: tab => vueModCard.data.activeTab = tab,
focus: el => {
el.style.zIndex = ++this.lastZIndex;
}, },
on: {
close: () => { close: () => {
this.open_mod_cards[user.login].remove(); const el = component.$el;
this.open_mod_cards[user.login] = null; 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;
} }
});
return vueModCard;
} }
})
}); });
return vueEl.$el; 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) { 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('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-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('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-event-bar', this.settings.get('player.hide-event-bar'));
this.css_tweaks.toggleHide('player-rerun-bar', this.settings.get('player.hide-rerun-bar')); this.css_tweaks.toggleHide('player-rerun-bar', this.settings.get('player.hide-rerun-bar'));
this.updateHideExtensions(); this.updateHideExtensions();
@ -257,6 +267,7 @@ export default class Player extends Module {
process(inst) { process(inst) {
this.addResetButton(inst); this.addResetButton(inst);
this.addEndedListener(inst); this.addEndedListener(inst);
this.addStateTags(inst);
this.addControlVisibility(inst); this.addControlVisibility(inst);
this.updateVolumeScroll(inst); this.updateVolumeScroll(inst);
} }
@ -298,6 +309,17 @@ export default class Player extends Module {
inst._ffz_autoplay_handler = null; 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) { disableAutoplay(inst) {
const p = inst.player; const p = inst.player;
if ( ! p ) if ( ! p )

View file

@ -111,10 +111,6 @@
cursor: pointer; cursor: pointer;
} }
.loading {
animation: ffz-rotateplane 1.2s infinite linear;
}
.ffz--menu-badge { .ffz--menu-badge {
width: 1.8rem; height: 1.8rem; 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 { try {
out.subscribe({ out.subscribe({
next: result => { next: result => {
if ( result.errors ) { // Logging GQL errors is garbage. Don't do it.
/*if ( result.errors ) {
const name = operation.operationName; const name = operation.operationName;
if ( name && (name.includes('FFZ') || has(this.modifiers, name) || has(this.post_modifiers, name)) ) { if ( name && (name.includes('FFZ') || has(this.modifiers, name) || has(this.post_modifiers, name)) ) {
for(const err of result.errors) { for(const err of result.errors) {
@ -142,7 +143,7 @@ export default class Apollo extends Module {
}); });
} }
} }
} }*/
this.log.crumb({ this.log.crumb({
level: 'info', 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) { _enter(target) {
let tip = target[this._accessor]; let tip = target[this._accessor];
if ( ! tip ) if ( ! tip )

View file

@ -102,6 +102,7 @@
.ffz-i-pin:before { content: '\e825'; } /* '' */ .ffz-i-pin:before { content: '\e825'; } /* '' */
.ffz-i-pin-outline:before { content: '\e826'; } /* '' */ .ffz-i-pin-outline:before { content: '\e826'; } /* '' */
.ffz-i-gift:before { content: '\e827'; } /* '' */ .ffz-i-gift:before { content: '\e827'; } /* '' */
.ffz-i-views:before { content: '\e828'; } /* '' */
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */ .ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */ .ffz-i-twitter:before { content: '\f099'; } /* '' */
.ffz-i-gauge:before { content: '\f0e4'; } /* '' */ .ffz-i-gauge:before { content: '\f0e4'; } /* '' */

View file

@ -10,3 +10,8 @@
75% { transform: rotateY(0deg) rotateX(0deg) } 75% { transform: rotateY(0deg) rotateX(0deg) }
100% { transform: rotateY(90deg) rotateX(0deg) } 100% { transform: rotateY(90deg) rotateX(0deg) }
} }
.ffz-i-zreknarf.loading {
animation: ffz-rotateplane 1.2s infinite linear;
}