mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 21:05:53 +00:00
4.37.0
* Added: "Copy Message" chat action for copying a message to your clipboard. * Added: Setting to pause the player by clicking on it. This is disabled by default, and the pause happens after half a second to avoid pausing as part of a double-click. * Added: Setting to clear the emote menu's search when closing it. * Added: Setting to hide the "Elevate Your Message" button in the chat input field. * Changed: Remove code related to channel hosting. * Fixed: Do not attempt to load FFZ on `gql` or `passport` subdomains. * Fixed: Channel leader-boards not being hidden on channels within a specific experiment.
This commit is contained in:
parent
bc0eab4409
commit
8cd6545556
16 changed files with 185 additions and 660 deletions
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "frankerfacez",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.36.2",
|
||||
"version": "4.37.0",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
@ -12,9 +12,9 @@
|
|||
"dev": "cross-env NODE_OPTIONS=--openssl-legacy-provider webpack-dev-server --config webpack.web.dev.js",
|
||||
"dev:prod": "cross-env NODE_OPTIONS=--openssl-legacy-provider webpack-dev-server --config webpack.web.dev.prod.js",
|
||||
"build": "pnpm build:prod",
|
||||
"build:stats": "cross-env NODE_ENV=production webpack --config webpack.web.prod.js --json > stats.json",
|
||||
"build:prod": "cross-env NODE_ENV=production webpack --config webpack.web.prod.js",
|
||||
"build:dev": "pnpm clean && webpack --config webpack.web.dev.js",
|
||||
"build:stats": "cross-env NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack --config webpack.web.prod.js --json > stats.json",
|
||||
"build:prod": "cross-env NODE_OPTIONS=--openssl-legacy-provider NODE_ENV=production webpack --config webpack.web.prod.js",
|
||||
"build:dev": "pnpm clean && cross-env NODE_OPTIONS=--openssl-legacy-provider webpack --config webpack.web.dev.js",
|
||||
"font": "pnpm font:edit",
|
||||
"font:edit": "fontello-cli --cli-config fontello.client.json edit",
|
||||
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
'use strict';
|
||||
(() => {
|
||||
// Don't run on certain sub-domains.
|
||||
if ( /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev)\./.test(location.hostname) )
|
||||
if ( /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev|gql|passport)\./.test(location.hostname) )
|
||||
return;
|
||||
|
||||
const DEBUG = localStorage.ffzDebugMode == 'true' && document.body.classList.contains('ffz-dev'),
|
||||
|
|
29
src/modules/chat/actions/components/edit-copy.vue
Normal file
29
src/modules/chat/actions/components/edit-copy.vue
Normal file
|
@ -0,0 +1,29 @@
|
|||
<template lang="html">
|
||||
<div class="tw-flex tw-align-items-start">
|
||||
<label for="edit_format" class="tw-mg-y-05">
|
||||
{{ t('setting.actions.format', 'Format') }}
|
||||
</label>
|
||||
|
||||
<div class="tw-full-width">
|
||||
<input
|
||||
id="edit_format"
|
||||
v-model="value.format"
|
||||
:placeholder="defaults.format"
|
||||
class="tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
|
||||
@input="$emit('input', value)"
|
||||
>
|
||||
|
||||
<div class="tw-c-text-alt-2 tw-mg-b-1">
|
||||
{{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: ['value', 'defaults', 'vars'],
|
||||
}
|
||||
|
||||
</script>
|
|
@ -83,6 +83,51 @@ export const edit_overrides = {
|
|||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Copy to Clipboard
|
||||
// ============================================================================
|
||||
|
||||
export const copy_message = {
|
||||
presets: [{
|
||||
appearance: {
|
||||
type: 'icon',
|
||||
icon: 'ffz-i-docs'
|
||||
}
|
||||
}],
|
||||
|
||||
defaults: {
|
||||
format: '{{user.displayName}}: {{message.text}}'
|
||||
},
|
||||
|
||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-copy.vue'),
|
||||
|
||||
required_context: ['user', 'message'],
|
||||
|
||||
title: 'Copy Message',
|
||||
description: 'Allows you to quickly copy a chat message to your clipboard.',
|
||||
|
||||
can_self: true,
|
||||
|
||||
tooltip(data) {
|
||||
const msg = this.replaceVariables(data.options.format, data);
|
||||
|
||||
return [
|
||||
(<div class="tw-border-b tw-mg-b-05">{ // eslint-disable-line react/jsx-key
|
||||
this.i18n.t('chat.actions.copy_message', 'Copy Message')
|
||||
}</div>),
|
||||
(<div class="tw-align-left">{ // eslint-disable-line react/jsx-key
|
||||
msg
|
||||
}</div>)
|
||||
];
|
||||
},
|
||||
|
||||
click(event, data) {
|
||||
const msg = this.replaceVariables(data.options.format, data);
|
||||
navigator.clipboard.writeText(msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Open URL
|
||||
// ============================================================================
|
||||
|
|
|
@ -559,6 +559,15 @@ export default class PlayerBase extends Module {
|
|||
},
|
||||
changed: val => this.css_tweaks.toggle('player-hide-mouse', val)
|
||||
});
|
||||
|
||||
this.settings.add('player.single-click-pause', {
|
||||
default: false,
|
||||
ui: {
|
||||
path: 'Player > General >> Playback',
|
||||
title: "Pause/Unpause the player by clicking.",
|
||||
component: 'setting-check-box'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onEnable() {
|
||||
|
@ -643,8 +652,6 @@ export default class PlayerBase extends Module {
|
|||
|
||||
|
||||
onShortcut(e) {
|
||||
this.log.info('Compressor Hotkey', e);
|
||||
|
||||
for(const inst of this.Player.instances)
|
||||
this.compressPlayer(inst, e);
|
||||
}
|
||||
|
@ -825,10 +832,14 @@ export default class PlayerBase extends Module {
|
|||
if ( ! this._ffz_click_handler )
|
||||
this._ffz_click_handler = this.ffzClickHandler.bind(this);
|
||||
|
||||
if ( ! this._ffz_dblclick_handler )
|
||||
this._ffz_dblclick_handler = this.ffzDblClickHandler.bind(this);
|
||||
|
||||
if ( ! this._ffz_menu_handler )
|
||||
this._ffz_menu_handler = this.ffzMenuHandler.bind(this);
|
||||
|
||||
on(cont, 'wheel', this._ffz_scroll_handler);
|
||||
on(cont, 'dblclick', this._ffz_dblclick_handler);
|
||||
on(cont, 'mousedown', this._ffz_click_handler);
|
||||
on(cont, 'contextmenu', this._ffz_menu_handler);
|
||||
}
|
||||
|
@ -853,23 +864,60 @@ export default class PlayerBase extends Module {
|
|||
this._ffz_menu_handler = null;
|
||||
}
|
||||
|
||||
if ( this._ffz_dblclick_handler ) {
|
||||
off(cont, 'dblclick', this._ffz_dblclick_handler);
|
||||
this._ffz_dblclick_handler = null;
|
||||
}
|
||||
|
||||
this._ffz_listeners = false;
|
||||
}
|
||||
|
||||
cls.prototype.ffzDelayPause = function() {
|
||||
if ( this._ffz_pause_timer )
|
||||
clearTimeout(this._ffz_pause_timer);
|
||||
|
||||
const player = this.props?.mediaPlayerInstance;
|
||||
if (! player.isPaused())
|
||||
this._ffz_pause_timer = setTimeout(() => {
|
||||
const player = this.props?.mediaPlayerInstance;
|
||||
if (!player.isPaused())
|
||||
player.pause();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
cls.prototype.ffzDblClickHandler = function(event) {
|
||||
if ( ! event )
|
||||
return;
|
||||
|
||||
if ( this._ffz_pause_timer )
|
||||
clearTimeout(this._ffz_pause_timer);
|
||||
}
|
||||
|
||||
cls.prototype.ffzClickHandler = function(event) {
|
||||
if ( ! event )
|
||||
return;
|
||||
|
||||
const vol_scroll = t.settings.get('player.volume-scroll'),
|
||||
gain_scroll = t.settings.get('player.gain.scroll'),
|
||||
click_pause = t.settings.get('player.single-click-pause'),
|
||||
|
||||
wants_rmb = wantsRMB(vol_scroll) || wantsRMB(gain_scroll);
|
||||
|
||||
// Left Click
|
||||
if (click_pause && event.button === 0) {
|
||||
if (! event.target || ! event.target.classList.contains('click-handler'))
|
||||
return;
|
||||
|
||||
this.ffzDelayPause();
|
||||
}
|
||||
|
||||
// Right Click
|
||||
if ( wants_rmb && event.button === 2 ) {
|
||||
this.ffz_rmb = true;
|
||||
this.ffz_scrolled = false;
|
||||
}
|
||||
|
||||
// Middle Click
|
||||
if ( ! t.settings.get('player.mute-click') || event.button !== 1 )
|
||||
return;
|
||||
|
||||
|
|
|
@ -79,7 +79,7 @@ export default class Channel extends Module {
|
|||
changed: () => this.updateLinks()
|
||||
});
|
||||
|
||||
this.settings.add('channel.hosting.enable', {
|
||||
/*this.settings.add('channel.hosting.enable', {
|
||||
default: true,
|
||||
ui: {
|
||||
path: 'Channel > Behavior >> Hosting',
|
||||
|
@ -87,8 +87,7 @@ export default class Channel extends Module {
|
|||
component: 'setting-check-box'
|
||||
},
|
||||
changed: val => ! val && this.InfoBar.each(el => this.updateBar(el))
|
||||
});
|
||||
|
||||
});*/
|
||||
|
||||
this.ChannelPanels = this.fine.define(
|
||||
'channel-panels',
|
||||
|
@ -116,7 +115,7 @@ export default class Channel extends Module {
|
|||
{childNodes: true, subtree: true}, 1
|
||||
);
|
||||
|
||||
const strip_host = resp => {
|
||||
/*const strip_host = resp => {
|
||||
if ( this.settings.get('channel.hosting.enable') )
|
||||
return;
|
||||
|
||||
|
@ -130,7 +129,7 @@ export default class Channel extends Module {
|
|||
};
|
||||
|
||||
this.apollo.registerModifier('UseHosting', strip_host, false);
|
||||
this.apollo.registerModifier('PlayerTrackingContextQuery', strip_host, false);
|
||||
this.apollo.registerModifier('PlayerTrackingContextQuery', strip_host, false);*/
|
||||
}
|
||||
|
||||
onEnable() {
|
||||
|
@ -162,7 +161,7 @@ export default class Channel extends Module {
|
|||
this.InfoBar.on('unmount', this.removeBar, this);
|
||||
this.InfoBar.each(el => this.updateBar(el));
|
||||
|
||||
this.subpump.on(':pubsub-message', this.onPubSub, this);
|
||||
//this.subpump.on(':pubsub-message', this.onPubSub, this);
|
||||
|
||||
this.router.on(':route', this.checkNavigation, this);
|
||||
this.checkNavigation();
|
||||
|
@ -230,7 +229,7 @@ export default class Channel extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
setHost(channel_id, channel_login, target_id, target_login) {
|
||||
/*setHost(channel_id, channel_login, target_id, target_login) {
|
||||
const topic = `stream-chat-room-v1.${channel_id}`;
|
||||
|
||||
this.subpump.inject(topic, {
|
||||
|
@ -272,7 +271,7 @@ export default class Channel extends Module {
|
|||
event.message.data.num_viewers = 0;
|
||||
event.markChanged();
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
updateSubscription(login) {
|
||||
|
@ -441,8 +440,8 @@ export default class Channel extends Module {
|
|||
});
|
||||
}*/
|
||||
|
||||
if ( ! this.settings.get('channel.hosting.enable') && props.hostLogin )
|
||||
this.setHost(props.channelID, props.channelLogin, null, null);
|
||||
//if ( ! this.settings.get('channel.hosting.enable') && props.hostLogin )
|
||||
// this.setHost(props.channelID, props.channelLogin, null, null);
|
||||
|
||||
this.updateSubscription(props.channelLogin);
|
||||
this.updateMetadata(el);
|
||||
|
@ -492,10 +491,10 @@ export default class Channel extends Module {
|
|||
live_since: props.liveSince
|
||||
},
|
||||
props,
|
||||
hosted: {
|
||||
/*hosted: {
|
||||
login: props.hostLogin,
|
||||
display_name: props.hostDisplayName
|
||||
},
|
||||
},*/
|
||||
el,
|
||||
getViewerCount: () => {
|
||||
const thing = cont.querySelector('p[data-a-target="animated-channel-viewers-count"]'),
|
||||
|
|
|
@ -188,6 +188,15 @@ export default class EmoteMenu extends Module {
|
|||
}
|
||||
});
|
||||
|
||||
this.settings.add('chat.emote-menu.clear-search', {
|
||||
default: false,
|
||||
ui: {
|
||||
path: 'Chat > Emote Menu >> General',
|
||||
title: 'Reset search when closing the Emote Menu.',
|
||||
component: 'setting-check-box'
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('chat.emote-menu.enabled', {
|
||||
default: true,
|
||||
ui: {
|
||||
|
@ -1078,6 +1087,7 @@ export default class EmoteMenu extends Module {
|
|||
reducedPadding: t.chat.context.get('chat.emote-menu.reduced-padding'),
|
||||
combineTabs: t.chat.context.get('chat.emote-menu.combine-tabs'),
|
||||
showSearch: t.chat.context.get('chat.emote-menu.show-search'),
|
||||
clearSearch: t.chat.context.get('chat.emote-menu.clear-search'),
|
||||
tone: t.settings.provider.get('emoji-tone', null)
|
||||
}
|
||||
|
||||
|
@ -1226,6 +1236,7 @@ export default class EmoteMenu extends Module {
|
|||
t.chat.context.on('changed:chat.emote-menu.show-heading', this.updateSettingState, this);
|
||||
t.chat.context.on('changed:chat.emote-menu.combine-tabs', this.updateSettingState, this);
|
||||
t.chat.context.on('changed:chat.emote-menu.show-search', this.updateSettingState, this);
|
||||
t.chat.context.on('changed:chat.emote-menu.clear-search', this.updateSettingState, this);
|
||||
t.chat.context.on('changed:chat.emote-menu.tall', this.updateSettingState, this);
|
||||
|
||||
window.ffz_menu = this;
|
||||
|
@ -1241,6 +1252,7 @@ export default class EmoteMenu extends Module {
|
|||
t.chat.context.off('changed:chat.emote-menu.reduced-padding', this.updateSettingState, this);
|
||||
t.chat.context.off('changed:chat.emote-menu.combine-tabs', this.updateSettingState, this);
|
||||
t.chat.context.off('changed:chat.emote-menu.show-search', this.updateSettingState, this);
|
||||
t.chat.context.off('changed:chat.emote-menu.clear-search', this.updateSettingState, this);
|
||||
t.chat.context.off('changed:chat.emote-menu.tall', this.updateSettingState, this);
|
||||
|
||||
if ( window.ffz_menu === this )
|
||||
|
@ -1256,6 +1268,7 @@ export default class EmoteMenu extends Module {
|
|||
reducedPadding: t.chat.context.get('chat.emote-menu.reduced-padding'),
|
||||
combineTabs: t.chat.context.get('chat.emote-menu.combine-tabs'),
|
||||
showSearch: t.chat.context.get('chat.emote-menu.show-search'),
|
||||
clearSearch: t.chat.context.get('chat.emote-menu.clear-search'),
|
||||
tall: t.chat.context.get('chat.emote-menu.tall')
|
||||
});
|
||||
}
|
||||
|
@ -2309,6 +2322,13 @@ export default class EmoteMenu extends Module {
|
|||
return;
|
||||
}
|
||||
|
||||
if ( ! this.props.visible && old_props.visible ) {
|
||||
if ( this.state.clearSearch ) {
|
||||
this.setState(this.filterState('', this.state));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cd = this.props.channel_data,
|
||||
old_cd = old_props.channel_data,
|
||||
cd_diff = cd?.user !== old_cd?.user || cd?.channel !== old_cd?.channel,
|
||||
|
|
|
@ -201,7 +201,7 @@ export default class ChatHook extends Module {
|
|||
|
||||
this.ChatController = this.fine.define(
|
||||
'chat-controller',
|
||||
n => n.hostingHandler && n.onRoomStateUpdated,
|
||||
n => n.parseOutgoingMessage && n.onRoomStateUpdated && n.renderNotifications,
|
||||
Twilight.CHAT_ROUTES
|
||||
);
|
||||
|
||||
|
@ -656,6 +656,15 @@ export default class ChatHook extends Module {
|
|||
component: 'setting-check-box'
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('chat.input.show-elevate-your-message', {
|
||||
default: true,
|
||||
ui: {
|
||||
path: 'Chat > Input >> Appearance',
|
||||
title: 'Allow the "Elevate Your Message" button to be displayed.',
|
||||
component: 'setting-check-box'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get currentChat() {
|
||||
|
@ -941,6 +950,9 @@ export default class ChatHook extends Module {
|
|||
this.updateMentionCSS();
|
||||
});
|
||||
|
||||
this.chat.context.getChanges('chat.input.show-elevate-your-message', val =>
|
||||
this.css_tweaks.toggleHide('elevate-your-message', ! val));
|
||||
|
||||
this.updateChatCSS();
|
||||
this.updateColors();
|
||||
this.updateLineBorders();
|
||||
|
@ -2441,7 +2453,7 @@ export default class ChatHook extends Module {
|
|||
return old_points.call(i, e);
|
||||
}
|
||||
|
||||
const old_host = this.onHostingEvent;
|
||||
/*const old_host = this.onHostingEvent;
|
||||
this.onHostingEvent = function (e, _t) {
|
||||
t.emit('tmi:host', e, _t);
|
||||
return old_host.call(i, e, _t);
|
||||
|
@ -2451,7 +2463,7 @@ export default class ChatHook extends Module {
|
|||
this.onUnhostEvent = function (e, _t) {
|
||||
t.emit('tmi:unhost', e, _t);
|
||||
return old_unhost.call(i, e, _t);
|
||||
}
|
||||
}*/
|
||||
|
||||
const old_add = this.addMessage;
|
||||
this.addMessage = function(e) {
|
||||
|
|
|
@ -26,6 +26,7 @@ const CLASSES = {
|
|||
'modview-hide-info': '.modview-player-widget__hide-stream-info',
|
||||
|
||||
'community-highlights': '.community-highlight-stack__card',
|
||||
'elevate-your-message': '.chat-input__input-icons button[aria-label="ElevatedMessage"]',
|
||||
|
||||
'prime-offers': '.top-nav__prime',
|
||||
'discover-luna': '.top-nav__external-link[data-a-target="try-presto-link"]',
|
||||
|
@ -38,7 +39,7 @@ const CLASSES = {
|
|||
'player-event-bar': '.channel-root .live-event-banner-ui__header',
|
||||
'player-rerun-bar': '.channel-root__player-container div.tw-c-text-overlay:not([data-a-target="hosting-ui-header"])',
|
||||
|
||||
'pinned-cheer': '.pinned-cheer,.pinned-cheer-v2,.channel-leaderboard',
|
||||
'pinned-cheer': '.pinned-cheer,.pinned-cheer-v2,.channel-leaderboard,.channel-leaderboard-marquee',
|
||||
'whispers': 'body .whispers-open-threads,.tw-core-button[data-a-target="whisper-box-button"],.whispers__pill',
|
||||
|
||||
'dir-live-ind': '.live-channel-card[data-ffz-type="live"] .tw-channel-status-text-indicator, article[data-ffz-type="live"] .tw-channel-status-text-indicator',
|
||||
|
|
|
@ -54,7 +54,7 @@ export default class Dashboard extends Module {
|
|||
this.settings.updateContext({
|
||||
channel: get('props.channelLogin', inst),
|
||||
channelID: get('props.channelID', inst),
|
||||
hosting: !! inst.props?.hostedChannel?.id
|
||||
//hosting: !! inst.props?.hostedChannel?.id
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -62,7 +62,7 @@ export default class Dashboard extends Module {
|
|||
this.settings.updateContext({
|
||||
channel: null,
|
||||
channelID: null,
|
||||
hosting: false
|
||||
//hosting: false
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,249 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Following Page
|
||||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import {createElement} from 'utilities/dom';
|
||||
import {get} from 'utilities/object';
|
||||
|
||||
import {createPopper} from '@popperjs/core';
|
||||
import {makeReference} from 'utilities/tooltip';
|
||||
|
||||
export default class Following extends SiteModule {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.inject('site.fine');
|
||||
this.inject('site.router');
|
||||
this.inject('site.apollo');
|
||||
this.inject('site.css_tweaks');
|
||||
|
||||
this.inject('i18n');
|
||||
this.inject('settings');
|
||||
|
||||
this.settings.add('directory.following.group-hosts', {
|
||||
default: true,
|
||||
|
||||
ui: {
|
||||
path: 'Directory > Following @{"description": "**Note:** These settings do not currently work due to changes made by Twitch to how the directory works."} >> Hosts',
|
||||
title: 'Group Hosts',
|
||||
description: 'Only show a given hosted channel once in the directory.',
|
||||
component: 'setting-check-box'
|
||||
},
|
||||
|
||||
changed: () => {
|
||||
this.apollo.maybeRefetch('FollowedIndex_CurrentUser');
|
||||
this.apollo.maybeRefetch('FollowingHosts_CurrentUser');
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('directory.following.host-menus', {
|
||||
default: 1,
|
||||
|
||||
ui: {
|
||||
path: 'Directory > Following >> Hosts',
|
||||
title: 'Hosted Channel Menus',
|
||||
description: 'Display a menu to select which channel to visit when clicking a hosted channel in the directory.',
|
||||
|
||||
component: 'setting-select-box',
|
||||
|
||||
data: [
|
||||
{value: 0, title: 'Disabled'},
|
||||
{value: 1, title: 'When Multiple are Hosting'},
|
||||
{value: 2, title: 'Always'}
|
||||
]
|
||||
},
|
||||
|
||||
changed: () => this.parent.DirectoryCard.forceUpdate()
|
||||
});
|
||||
|
||||
this.hosts = new WeakMap;
|
||||
}
|
||||
|
||||
modifyLiveUsers(res, path = 'followedLiveUsers') {
|
||||
const followed_live = get(`data.currentUser.${path}`, res);
|
||||
if ( ! followed_live )
|
||||
return;
|
||||
|
||||
if ( followed_live.nodes )
|
||||
followed_live.nodes = this.parent.processNodes(followed_live.nodes);
|
||||
|
||||
else if ( followed_live.edges )
|
||||
followed_live.edges = this.parent.processNodes(followed_live.edges);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
modifyLiveHosts(res) {
|
||||
const blocked_games = this.settings.provider.get('directory.game.blocked-games', []),
|
||||
do_grouping = this.settings.get('directory.following.group-hosts'),
|
||||
edges = get('data.currentUser.followedHosts.nodes', res);
|
||||
|
||||
if ( ! edges || ! edges.length )
|
||||
return res;
|
||||
|
||||
this.hosts = new WeakMap();
|
||||
const out = [];
|
||||
|
||||
for(const edge of edges) {
|
||||
if ( ! edge )
|
||||
continue;
|
||||
|
||||
const node = edge.node || edge,
|
||||
hosted = node.hosting,
|
||||
stream = hosted && hosted.stream;
|
||||
|
||||
if ( ! stream || stream.game && blocked_games.includes(stream.game.name) )
|
||||
continue;
|
||||
|
||||
if ( ! stream.viewersCount ) {
|
||||
if ( ! do_grouping || ! this.hosts[hosted.login] )
|
||||
out.push(edge);
|
||||
continue;
|
||||
}
|
||||
|
||||
const store = stream.viewersCount = new Number(stream.viewersCount || 0);
|
||||
|
||||
store.createdAt = stream.createdAt;
|
||||
store.title = stream.title;
|
||||
//store.game = stream.game;
|
||||
|
||||
if ( do_grouping ) {
|
||||
const host_nodes = this.hosts[hosted.login];
|
||||
if ( host_nodes ) {
|
||||
host_nodes.push(node);
|
||||
this.hosts.set(store, host_nodes);
|
||||
|
||||
} else {
|
||||
this.hosts.set(store, this.hosts[hosted.login] = [node]);
|
||||
out.push(edge);
|
||||
}
|
||||
|
||||
} else
|
||||
out.push(edge);
|
||||
}
|
||||
|
||||
res.data.currentUser.followedHosts.nodes = out;
|
||||
return res;
|
||||
}
|
||||
|
||||
onEnable() {
|
||||
document.body.addEventListener('click', this.destroyHostMenu.bind(this));
|
||||
}
|
||||
|
||||
destroyHostMenu(event) {
|
||||
if (!event || ! this.hostMenu || event && event.target && event.target.closest('.ffz-channel-selector-outer') === null && Date.now() > this.hostMenuBuffer) {
|
||||
this.hostMenuPopper && this.hostMenuPopper.destroy();
|
||||
this.hostMenu && this.hostMenu.remove();
|
||||
this.hostMenuPopper = this.hostMenu = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
showHostMenu(inst, channels, event) {
|
||||
if (this.settings.get('directory.following.host-menus') === 0 || this.settings.get('directory.following.host-menus') === 1 && channels.length < 2) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.hostMenuPopper && this.hostMenuPopper.destroy();
|
||||
|
||||
this.hostMenu && this.hostMenu.remove();
|
||||
|
||||
const simplebarContentChildren = [];
|
||||
|
||||
// Hosted Channel Header
|
||||
simplebarContentChildren.push(<p class="tw-pd-t-05 tw-pd-x-1 tw-c-text-alt-2">
|
||||
{this.i18n.t('directory.hosted', 'Hosted Channel')}
|
||||
</p>);
|
||||
|
||||
// Hosted Channel Content
|
||||
simplebarContentChildren.push(<a
|
||||
class="tw-block tw-border-radius-small tw-full-width ffz-interactable ffz-interactable--default ffz-interactable--hover-enabled tw-interactive"
|
||||
href={`/${inst.props.channelLogin}`}
|
||||
onClick={e => this.parent.hijackUserClick(e, inst.props.channelLogin, this.destroyHostMenu.bind(this))} // eslint-disable-line react/jsx-no-bind
|
||||
>
|
||||
<div class="tw-align-items-center tw-flex tw-flex-row tw-flex-nowrap tw-mg-x-1 tw-mg-y-05">
|
||||
<div class="ffz-channel-avatar">
|
||||
<img src={inst.props.channelImageProps.src} alt={inst.props.channelDisplayName} />
|
||||
</div>
|
||||
<p class="tw-ellipsis tw-flex-grow-1 tw-mg-l-1 tw-font-size-5">
|
||||
{inst.props.channelDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
</a>);
|
||||
|
||||
// Hosting Channels Header
|
||||
simplebarContentChildren.push(<p class="tw-pd-t-05 tw-pd-x-1 tw-c-text-alt-2">
|
||||
{this.i18n.t('directory.hosting', 'Hosting Channels')}
|
||||
</p>);
|
||||
|
||||
// Hosting Channels Content
|
||||
for (const channel of channels) {
|
||||
simplebarContentChildren.push(<a
|
||||
class="tw-block tw-border-radius-small tw-full-width ffz-interactable ffz-interactable--default ffz-interactable--hover-enabled tw-interactive"
|
||||
href={`/${channel.login}`}
|
||||
onClick={e => this.parent.hijackUserClick(e, channel.login, this.destroyHostMenu.bind(this))} // eslint-disable-line react/jsx-no-bind
|
||||
>
|
||||
<div class="tw-align-items-center tw-flex tw-flex-row tw-flex-nowrap tw-mg-x-1 tw-mg-y-05">
|
||||
<div class="ffz-channel-avatar">
|
||||
<img src={channel.profileImageURL} alt={channel.displayName} />
|
||||
</div>
|
||||
<p class="tw-ellipsis tw-flex-grow-1 tw-mg-l-1 tw-font-size-5">
|
||||
{channel.displayName}
|
||||
</p>
|
||||
</div>
|
||||
</a>);
|
||||
}
|
||||
|
||||
this.hostMenu = (<div class="ffz-host-menu ffz-balloon tw-block">
|
||||
<div class="tw-border tw-elevation-1 tw-border-radius-small tw-c-background-base tw-pd-05">
|
||||
<div class="scrollable-area" data-simplebar>
|
||||
{simplebarContentChildren}
|
||||
</div>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
const root = (document.body.querySelector('#root>div') || document.body);
|
||||
root.appendChild(this.hostMenu);
|
||||
|
||||
this.hostMenuPopper = createPopper(
|
||||
makeReference(event.clientX - 60, event.clientY - 60),
|
||||
this.hostMenu,
|
||||
{
|
||||
placement: 'bottom-start',
|
||||
modifiers: {
|
||||
flip: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.hostMenuBuffer = Date.now() + 50;
|
||||
}
|
||||
|
||||
updateChannelCard(inst) {
|
||||
const card = this.fine.getChildNode(inst);
|
||||
|
||||
if ( ! card )
|
||||
return;
|
||||
|
||||
const login = inst.props.channelLogin,
|
||||
hosting = inst.props.channelLinkTo && inst.props.channelLinkTo.state.content === 'live_host' && this.hosts && this.hosts[login];
|
||||
|
||||
if ( hosting && this.settings.get('directory.following.group-hosts') ) {
|
||||
const host_data = this.hosts[login];
|
||||
|
||||
const title_link = card.querySelector('a[data-test-selector="preview-card-titles__primary-link"]'),
|
||||
thumbnail_link = card.querySelector('a[data-a-target="preview-card-image-link"]');
|
||||
|
||||
if ( title_link )
|
||||
title_link.addEventListener('click', this.showHostMenu.bind(this, inst, host_data));
|
||||
|
||||
if ( thumbnail_link )
|
||||
thumbnail_link.addEventListener('click', this.showHostMenu.bind(this, inst, host_data));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -48,7 +48,6 @@ export default class Directory extends SiteModule {
|
|||
this.inject('i18n');
|
||||
this.inject('settings');
|
||||
|
||||
//this.inject(Following);
|
||||
this.inject(Game);
|
||||
|
||||
this.DirectoryCard = this.elemental.define(
|
||||
|
|
|
@ -1,379 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Host Button
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import {get} from 'utilities/object';
|
||||
import {createElement} from 'utilities/dom';
|
||||
|
||||
const HOST_ERRORS = {
|
||||
COMMAND_EXECUTION: {
|
||||
key: 'command-execution',
|
||||
text: 'There was an error executing the host command. Please try again later.',
|
||||
},
|
||||
CHAT_CONNECTION: {
|
||||
key: 'chat-connection',
|
||||
text: 'There was an issue connecting to chat. Please try again later.',
|
||||
}
|
||||
};
|
||||
|
||||
export default class HostButton extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this.inject('site');
|
||||
this.inject('site.fine');
|
||||
this.inject('site.chat');
|
||||
this.inject('site.twitch_data');
|
||||
this.inject('i18n');
|
||||
this.inject('metadata');
|
||||
this.inject('settings');
|
||||
|
||||
this.settings.add('metadata.host-button', {
|
||||
default: true,
|
||||
|
||||
ui: {
|
||||
path: 'Channel > Metadata >> Player',
|
||||
title: 'Host Button',
|
||||
description: 'Show a host button with the current hosted channel in the tooltip.',
|
||||
component: 'setting-check-box'
|
||||
},
|
||||
|
||||
changed: () => {
|
||||
const ffz_user = this.site.getUser(),
|
||||
userLogin = ffz_user && ffz_user.login;
|
||||
|
||||
if (userLogin)
|
||||
this.joinChannel(userLogin);
|
||||
|
||||
this.metadata.updateMetadata('host');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isChannelHosted(channelLogin) {
|
||||
return this._last_hosted_channel === channelLogin;
|
||||
}
|
||||
|
||||
sendHostUnhostCommand(channel) {
|
||||
if (!this._chat_con) {
|
||||
this._host_error = HOST_ERRORS.CHAT_CONNECTION;
|
||||
this._host_updating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const ffz_user = this.site.getUser(),
|
||||
userLogin = ffz_user && ffz_user.login;
|
||||
|
||||
const commandData = {channel: userLogin, username: channel};
|
||||
|
||||
this._host_updating = true;
|
||||
this.metadata.updateMetadata('host');
|
||||
|
||||
this._host_feedback = setTimeout(() => {
|
||||
if (this._last_hosted_channel === null) {
|
||||
this._host_error = HOST_ERRORS.COMMAND_EXECUTION;
|
||||
this._host_updating = false;
|
||||
this.metadata.updateMetadata('host');
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
if (this.isChannelHosted(channel)) {
|
||||
this._chat_con.commands.unhost.execute(commandData);
|
||||
} else {
|
||||
this._chat_con.commands.host.execute(commandData);
|
||||
}
|
||||
}
|
||||
|
||||
joinChannel(channel) {
|
||||
if (this._chat_con) {
|
||||
if (this.settings.get('metadata.host-button') && !this._chat_con.session.channelstate[`#${channel}`]) {
|
||||
this._chat_con.joinChannel(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hookIntoChatConnection(inst) {
|
||||
const userLogin = inst.props.currentUserLogin;
|
||||
|
||||
if (this._chat_con) {
|
||||
this.joinChannel(userLogin);
|
||||
return;
|
||||
}
|
||||
|
||||
this.on('tmi:host', e => {
|
||||
if (e.channel.substring(1) !== userLogin) return;
|
||||
|
||||
clearTimeout(this._host_feedback);
|
||||
this._host_error = false;
|
||||
this._last_hosted_channel = e.target;
|
||||
|
||||
this._host_updating = false;
|
||||
this.metadata.updateMetadata('host');
|
||||
});
|
||||
|
||||
this.on('tmi:unhost', e => {
|
||||
if (e.channel.substring(1) !== userLogin) return;
|
||||
|
||||
clearTimeout(this._host_feedback);
|
||||
this._host_error = false;
|
||||
this._last_hosted_channel = null;
|
||||
|
||||
this._host_updating = false;
|
||||
this.metadata.updateMetadata('host');
|
||||
});
|
||||
|
||||
const chatServiceClient = inst.client;
|
||||
|
||||
this._chat_con = chatServiceClient;
|
||||
if (this.settings.get('metadata.host-button'))
|
||||
this.joinChannel(userLogin);
|
||||
}
|
||||
|
||||
onEnable() {
|
||||
this.on('i18n:update', () => this.metadata.updateMetadata('host'));
|
||||
|
||||
this.metadata.definitions.host = {
|
||||
order: 150,
|
||||
border: true,
|
||||
button: true,
|
||||
fade_in: true,
|
||||
modview: true,
|
||||
|
||||
disabled: () => this._host_updating || this._host_error,
|
||||
|
||||
click: data => {
|
||||
if ( data.channel )
|
||||
this.sendHostUnhostCommand(data.channel.login);
|
||||
},
|
||||
|
||||
popup: async (data, tip) => {
|
||||
const vue = this.resolve('vue'),
|
||||
_host_options_vue = import(/* webpackChunkName: "host-options" */ './host-options.vue'),
|
||||
_autoHosts = this.fetchAutoHosts(),
|
||||
_autoHostSettings = this.fetchAutoHostSettings();
|
||||
|
||||
const [, host_options_vue, autoHosts, autoHostSettings] = await Promise.all([vue.enable(), _host_options_vue, _autoHosts, _autoHostSettings]);
|
||||
|
||||
this._auto_host_tip = tip;
|
||||
tip.element.classList.remove('tw-pd-1');
|
||||
tip.element.classList.add('ffz-balloon--lg');
|
||||
vue.component('host-options', host_options_vue.default);
|
||||
return this.buildAutoHostMenu(vue, autoHosts, autoHostSettings, data.channel);
|
||||
},
|
||||
|
||||
label: data => {
|
||||
const ffz_user = this.site.getUser();
|
||||
|
||||
if ( ! this.settings.get('metadata.host-button') || ! ffz_user || ! data.channel || data.channel.login === ffz_user.login )
|
||||
return;
|
||||
|
||||
if ( data.channel.video && ! this.isChannelHosted(data.channel.login) )
|
||||
return;
|
||||
|
||||
if ( this._host_updating )
|
||||
return this.i18n.t('metadata.host-button.updating', 'Updating...');
|
||||
|
||||
return (this._last_hosted_channel && this.isChannelHosted(data.channel && data.channel.login))
|
||||
? this.i18n.t('metadata.host-button.unhost', 'Unhost')
|
||||
: this.i18n.t('metadata.host-button.host', 'Host');
|
||||
},
|
||||
|
||||
tooltip: () => {
|
||||
if (this._host_error) {
|
||||
return this.i18n.t(
|
||||
`metadata.host-button.tooltip.error.${this._host_error.key}`,
|
||||
this._host_error.text);
|
||||
} else {
|
||||
return this.i18n.t('metadata.host-button.tooltip',
|
||||
'Currently hosting: {channel}',
|
||||
{
|
||||
channel: this._last_hosted_channel || this.i18n.t('metadata.host-button.tooltip.none', 'None')
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.metadata.updateMetadata('host');
|
||||
|
||||
this.chat.ChatService.ready((cls, instances) => {
|
||||
for(const inst of instances)
|
||||
this.hookIntoChatConnection(inst);
|
||||
})
|
||||
|
||||
this.chat.ChatService.on('mount', this.hookIntoChatConnection, this);
|
||||
}
|
||||
|
||||
buildAutoHostMenu(vue, hosts, autoHostSettings, data) {
|
||||
this._current_channel_id = data.id;
|
||||
this.activeTab = this.activeTab || 'auto-host';
|
||||
|
||||
const vueEl = new vue.Vue({
|
||||
el: createElement('div'),
|
||||
render: h => this.vueHostMenu = h('host-options', {
|
||||
hosts,
|
||||
autoHostSettings,
|
||||
activeTab: this.activeTab,
|
||||
|
||||
addedToHosts: this.currentRoomInHosts(),
|
||||
addToAutoHosts: () => this.addCurrentRoomToHosts(),
|
||||
rearrangeHosts: event => this.rearrangeHosts(event.oldIndex, event.newIndex),
|
||||
removeFromHosts: event => this.removeUserFromHosts(event),
|
||||
setActiveTab: tab => {
|
||||
this.vueHostMenu.data.activeTab = this.activeTab = tab;
|
||||
},
|
||||
updatePopper: () => {
|
||||
if (this._auto_host_tip) this._auto_host_tip.update();
|
||||
},
|
||||
updateCheckbox: e => {
|
||||
const t = e.target;
|
||||
let setting = t.dataset.setting,
|
||||
state = t.checked;
|
||||
|
||||
if ( setting === 'enabled' )
|
||||
setting = 'isEnabled';
|
||||
else if ( setting === 'teamHost' )
|
||||
setting = 'willAutohostTeam';
|
||||
else if ( setting === 'strategy' )
|
||||
state = state ? 'RANDOM' : 'ORDERED';
|
||||
else if ( setting === 'deprioritizeVodcast' ) {
|
||||
setting = 'willPrioritizeAutohost';
|
||||
}
|
||||
|
||||
this.updateAutoHostSetting(setting, state);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return vueEl.$el;
|
||||
}
|
||||
|
||||
async fetchAutoHosts() {
|
||||
const user = this.site.getUser();
|
||||
if ( ! user )
|
||||
return;
|
||||
|
||||
const result = await this.twitch_data.queryApollo(
|
||||
await import(/* webpackChunkName: 'host-options' */ './autohost_list.gql'),
|
||||
{
|
||||
id: user.id
|
||||
},
|
||||
{
|
||||
fetchPolicy: 'network-only'
|
||||
}
|
||||
);
|
||||
|
||||
return this.autoHosts = get('data.user.autohostChannels.nodes', result);
|
||||
}
|
||||
|
||||
async fetchAutoHostSettings() {
|
||||
const user = this.site.getUser();
|
||||
if ( ! user )
|
||||
return;
|
||||
|
||||
const result = await this.twitch_data.queryApollo(
|
||||
await import(/* webpackChunkName: 'host-options' */ './autohost_settings.gql'),
|
||||
{
|
||||
id: user.id
|
||||
},
|
||||
{
|
||||
fetchPolicy: 'network-only'
|
||||
}
|
||||
);
|
||||
|
||||
return this.autoHostSettings = get('data.user.autohostSettings', result);
|
||||
}
|
||||
|
||||
queueHostUpdate() {
|
||||
if (this._host_update_timer) clearTimeout(this._host_update_timer);
|
||||
|
||||
this._host_update_timer = setTimeout(() => {
|
||||
this._host_update_timer = undefined;
|
||||
this.updateAutoHosts(this.autoHosts);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
rearrangeHosts(oldIndex, newIndex) {
|
||||
const host = this.autoHosts.splice(oldIndex, 1)[0];
|
||||
this.autoHosts.splice(newIndex, 0, host);
|
||||
|
||||
this.queueHostUpdate();
|
||||
}
|
||||
|
||||
currentRoomInHosts() {
|
||||
return this.getAutoHostIDs(this.autoHosts).includes(this._current_channel_id);
|
||||
}
|
||||
|
||||
addCurrentRoomToHosts() {
|
||||
const newHosts = this.autoHosts.slice(0);
|
||||
newHosts.push({ id: this._current_channel_id});
|
||||
|
||||
this.updateAutoHosts(newHosts);
|
||||
}
|
||||
|
||||
removeUserFromHosts(event) {
|
||||
const id = event.target.closest('.ffz--host-user').dataset.id;
|
||||
|
||||
const newHosts = [];
|
||||
for (let i = 0; i < this.autoHosts.length; i++) {
|
||||
if (this.autoHosts[i].id != id) newHosts.push(this.autoHosts[i]);
|
||||
}
|
||||
|
||||
this.updateAutoHosts(newHosts);
|
||||
}
|
||||
|
||||
getAutoHostIDs(hosts) { // eslint-disable-line class-methods-use-this
|
||||
const ids = [];
|
||||
if (hosts) {
|
||||
for (let i = 0; i < hosts.length; i++) {
|
||||
ids.push(hosts[i].id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async updateAutoHosts(newHosts) {
|
||||
const user = this.site.getUser();
|
||||
if ( ! user )
|
||||
return;
|
||||
|
||||
const autoHosts = this.getAutoHostIDs(newHosts);
|
||||
|
||||
const result = await this.twitch_data.mutate({
|
||||
mutation: await import(/* webpackChunkName: 'host-options' */ './autohost_list_mutate.gql'),
|
||||
variables: {
|
||||
userID: user.id,
|
||||
channelIDs: autoHosts
|
||||
}
|
||||
});
|
||||
|
||||
this.autoHosts = get('data.setAutohostChannels.user.autohostChannels.nodes', result);
|
||||
if (this.vueHostMenu) {
|
||||
this.vueHostMenu.data.hosts = this.autoHosts;
|
||||
this.vueHostMenu.data.addedToHosts = this.currentRoomInHosts();
|
||||
}
|
||||
}
|
||||
|
||||
async updateAutoHostSetting(setting, newValue) {
|
||||
const user = this.site.getUser();
|
||||
if ( ! user )
|
||||
return;
|
||||
|
||||
const result = await this.twitch_data.mutate({
|
||||
mutation: await import(/* webpackChunkName: 'host-options' */ './autohost_settings_mutate.gql'),
|
||||
variables: {
|
||||
userID: user.id,
|
||||
[setting]: newValue
|
||||
}
|
||||
});
|
||||
|
||||
this.autoHostSettings = get('data.updateAutohostSettings.user.autohostSettings', result);
|
||||
if (this.vueHostMenu) {
|
||||
this.vueHostMenu.data.autoHostSettings = this.autoHostSettings;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -172,7 +172,7 @@ export default class Layout extends Module {
|
|||
});
|
||||
|
||||
this.settings.add('layout.portrait-extra-height', {
|
||||
requires: ['context.new_channel', 'context.squad_bar', 'context.hosting', 'context.ui.theatreModeEnabled', 'player.theatre.no-whispers', 'whispers.show', 'layout.minimal-navigation'],
|
||||
requires: ['context.new_channel', 'context.squad_bar', /*'context.hosting',*/ 'context.ui.theatreModeEnabled', 'player.theatre.no-whispers', 'whispers.show', 'layout.minimal-navigation'],
|
||||
process(ctx) {
|
||||
let height = 0;
|
||||
if ( ctx.get('context.ui.theatreModeEnabled') ) {
|
||||
|
@ -192,8 +192,8 @@ export default class Layout extends Module {
|
|||
|
||||
height += ctx.get('context.new_channel') ? 1 : 5;
|
||||
|
||||
if ( ctx.get('context.hosting') )
|
||||
height += 4;
|
||||
/*if ( ctx.get('context.hosting') )
|
||||
height += 4;*/
|
||||
}
|
||||
|
||||
return height;
|
||||
|
|
|
@ -196,7 +196,7 @@ export default class Player extends PlayerBase {
|
|||
|
||||
|
||||
checkCarousel(inst) {
|
||||
if ( this.settings.get('channel.hosting.enable') )
|
||||
/*if ( this.settings.get('channel.hosting.enable') )
|
||||
return;
|
||||
|
||||
if ( inst.props?.playerType === 'channel_home_carousel' ) {
|
||||
|
@ -211,7 +211,7 @@ export default class Player extends PlayerBase {
|
|||
events = inst.props.playerEvents;
|
||||
|
||||
this.stopPlayer(player, events, inst);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
@import 'fixes';
|
||||
|
||||
@import 'host_options';
|
||||
//@import 'host_options';
|
||||
@import 'featured_follow';
|
||||
@import 'mod_card';
|
||||
@import 'easteregg';
|
Loading…
Add table
Add a link
Reference in a new issue