1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-07-31 06:58:30 +00:00

Directory Support & Apollo InlineFragment fix (#338)

* Directory Support

- Channel avatar next to / on-top the stream card
- Stream uptime on stream card
- Host grouping with a nice UI
- Ability to block / hide games and / or their thumbnails

Missing / Not working as of right now:
- Channel avatar / uptime not showing in Game or Community directories
Fix not being able to open host menu on the stream name


Fix chat lines where you've been mentioned.


"Following" button is now hoverable

Not configurable though.
Merge remote-tracking branch 'upstream/master'


Fix for Apollo's InlineFragments


Basic game directory index support

(Outsource timeToString to some utility class!!!)
Empty merge

Fix Github's "You're behind the master branch!" annoyance
More work on Directory stuff

Community pages are now supported
Several other small things

* Proper router usage

* Several fixes

Also removing `package-lock.json` because for whatever reason that was in?

* Early return if the container or card are null
This commit is contained in:
Lordmau5 2017-11-28 08:16:37 +01:00 committed by Mike
parent 2b6e93ab11
commit 6a7a19bf83
9 changed files with 1501 additions and 7467 deletions

7387
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -99,6 +99,7 @@ Twilight.KNOWN_MODULES = {
Twilight.ROUTES = {
'front-page': '/',
'collection': '/collections/:collectionID',
'dir': '/directory',
'dir-community': '/communities/:communityName',
'dir-community-index': '/directory/communities',
'dir-creative': '/directory/creative',

View file

@ -21,7 +21,12 @@ const CLASSES = {
'player-ext-hover': '.player[data-controls="false"] .player-extensions',
'pinned-cheer': '.pinned-cheer',
'whispers': '.whispers'
'whispers': '.whispers',
'boxart-hover': '.tw-card .full-width:hover a[data-a-target="live-channel-card-game-link"]',
'boxart-hide': '.tw-card a[data-a-target="live-channel-card-game-link"]',
'profile-hover-following': '.tw-card .full-width:hover .channel-avatar',
'profile-hover-game': '.tw-thumbnail-card .tw-card-img:hover .channel-avatar',
};

View file

@ -0,0 +1,305 @@
'use strict';
// ============================================================================
// Directory (Following, for now)
// ============================================================================
import {SiteModule} from 'utilities/module';
import {createElement as e} from 'utilities/dom';
import {duration_to_string} from 'utilities/time';
export default class Community 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('settings');
this.GameHeader = this.fine.define(
'game-header',
n => n.renderFollowButton && n.renderGameDetailsTab
);
this.apollo.registerModifier('GamePage_Game', `query {
directory {
... on Community {
streams {
edges {
node {
createdAt
type
broadcaster {
profileImageURL(width: 70)
}
}
}
}
}
}
}`);
this.ChannelCard = this.fine.define(
'community-channel-card',
n => n.props && n.props.streamNode
);
this.apollo.registerModifier('GamePage_Game', res => this.router.current.name === 'dir-community' && this.modifyStreams(res), false);
this.on('settings:changed:show-channel-avatar', value => {
this.css_tweaks.toggleHide('profile-hover-game', value === 2);
this.router.current.name === 'dir-community' && this.ChannelCard.forceUpdate();
});
this.on('settings:changed:directory.following.uptime', () => this.router.current.name === 'dir-community' && this.ChannelCard.forceUpdate());
}
modifyStreams(res) { // eslint-disable-line class-methods-use-this
const newStreams = [];
const edges = res.data.directory.streams.edges;
for (let i = 0; i < edges.length; i++) {
const edge = edges[i];
const node = edge.node;
const s = node.viewersCount = new Number(node.viewersCount || 0);
s.profileImageURL = node.broadcaster.profileImageURL;
s.createdAt = node.createdAt;
newStreams.push(edge);
}
res.data.directory.streams.edges = newStreams;
return res;
}
onEnable() {
this.GameHeader.ready((cls, instances) => {
if (this.router.current.name === 'dir-community') {
for(const inst of instances) this.updateButtons(inst);
}
});
this.ChannelCard.ready((cls, instances) => {
if (this.router.current.name === 'dir-community') {
this.apollo.ensureQuery(
'GamePage_Game',
'data.directory.streams.edges.0.node.createdAt'
);
for(const inst of instances) this.updateChannelCard(inst);
}
});
this.ChannelCard.on('update', inst => this.router.current.name === 'dir-community' && this.updateChannelCard(inst), this);
this.ChannelCard.on('mount', inst => this.router.current.name === 'dir-community' && this.updateChannelCard(inst), this);
this.ChannelCard.on('unmount', inst => this.router.current.name === 'dir-community' && this.updateUptime(inst), this);
this.css_tweaks.toggleHide('profile-hover-game', this.settings.get('directory.following.show-channel-avatar') === 2);
}
updateUptime(inst) {
const container = this.fine.getHostNode(inst);
const card = container && container.querySelector && container.querySelector('.tw-card');
if (container === null || card === null) {
if (inst.updateTimer !== undefined) {
clearInterval(inst.updateTimer);
inst.updateTimer = undefined;
return;
}
}
if (this.settings.get('directory.following.uptime') === 0) {
if (inst.updateTimer !== undefined) {
clearInterval(inst.updateTimer);
inst.updateTimer = undefined;
}
if (inst.uptimeElement !== undefined) {
inst.uptimeElement.remove();
inst.uptimeElementSpan = inst.uptimeElement = undefined;
}
} else {
if (inst.updateTimer === undefined) {
inst.updateTimer = setInterval(
this.updateUptime.bind(this, inst),
1000
);
}
const up_since = new Date(inst.props.streamNode.viewersCount.createdAt);
const uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
const uptimeText = duration_to_string(uptime, false, false, false, this.settings.get('directory.following.uptime') === 1);
if (uptime > 0) {
if (inst.uptimeElement === undefined) {
inst.uptimeElementSpan = e('span', 'tw-stat__value ffz-uptime', `${uptimeText}`);
inst.uptimeElement = e('div', {
className: 'c-background-overlay c-text-overlay font-size-6 top-0 right-0 z-default inline-flex absolute mg-05',
style: 'padding-left: 4px; padding-right: 4px;'
}, [
e('span', 'tw-stat__icon',
e('figure', 'ffz-i-clock')
),
inst.uptimeElementSpan
]);
if (card.querySelector('.ffz-uptime') === null) card.appendChild(inst.uptimeElement);
} else {
inst.uptimeElementSpan.textContent = `${uptimeText}`;
}
}
}
}
updateChannelCard(inst) {
this.updateUptime(inst);
const container = this.fine.getHostNode(inst);
const card = container && container.querySelector && container.querySelector('.tw-card');
if (container === null || card === null) return;
if (!inst.props.streamNode.viewersCount.createdAt) return;
// Remove old elements
const hiddenBodyCard = card.querySelector('.tw-card-body.hide');
if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('hide');
const ffzChannelData = card.querySelector('.ffz-channel-data');
if (ffzChannelData !== null) ffzChannelData.remove();
const channelAvatar = card.querySelector('.channel-avatar');
if (channelAvatar !== null) channelAvatar.remove();
if (inst.props.streamNode.viewersCount.profileImageURL) {
const avatarSetting = this.settings.get('directory.following.show-channel-avatar');
if (avatarSetting === 1) {
const cardDiv = card.querySelector('.tw-card-body');
const modifiedDiv = e('div', {
innerHTML: cardDiv.innerHTML
});
const avatarDiv = e('a', {
className: 'channel-avatar',
href: `/${inst.props.streamNode.broadcaster.login}`,
style: 'margin-right: 8px; min-width: 4rem; margin-top: 0.5rem;',
onclick: event => {
event.preventDefault();
event.stopPropagation();
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
}
}, e('img', {
title: inst.props.streamNode.broadcaster.displayName,
src: inst.props.streamNode.viewersCount.profileImageURL,
style: 'height: 4rem;'
}));
const cardDivParent = cardDiv.parentElement;
if (cardDivParent.querySelector('.ffz-channel-data') === null) {
cardDiv.classList.add('hide');
const newCardDiv = e('div', 'ffz-channel-data flex flex-nowrap', [
avatarDiv, modifiedDiv
]);
cardDivParent.appendChild(newCardDiv);
}
} else if (avatarSetting === 2 || avatarSetting === 3) {
const avatarElement = e('a', {
className: 'channel-avatar',
href: `/${inst.props.streamNode.broadcaster.login}`,
onclick: event => {
event.preventDefault();
event.stopPropagation();
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
}
}, e('div', 'live-channel-card__boxart bottom-0 absolute',
e('figure', 'tw-aspect tw-aspect--align-top',
e('img', {
title: inst.props.streamNode.broadcaster.displayName,
src: inst.props.streamNode.viewersCount.profileImageURL
})
)
)
);
const divToAppend = card.querySelector('figure.tw-aspect');
if (divToAppend.querySelector('.channel-avatar') === null) divToAppend.appendChild(avatarElement);
}
}
}
updateButtons(inst) {
const container = this.fine.getHostNode(inst);
// We can't get the buttons through querySelector('button ...') so this has to do for now...
const buttons = container && container.querySelector && container.querySelector('div > div.align-items-center');
const ffzButtons = buttons.querySelector('.ffz-buttons');
if (ffzButtons !== null) ffzButtons.remove();
if (buttons.querySelector('.ffz-buttons') === null) {
// Block / Unblock Games
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
const gameBlocked = blockedGames.includes(inst.props.directoryName);
const blockButton = e('button', {
className: 'mg-l-1 tw-button ffz-toggle-game-block',
style: `background-color: ${gameBlocked ? '#228B22' : '#B22222'};`
}, e('span', {
className: 'tw-button__text',
textContent: `${gameBlocked ? 'Unblock' : 'Block'}`
})
);
blockButton.addEventListener('click', () => {
const gameName = inst.props.directoryName;
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
if (blockedGames.includes(gameName)) blockedGames.splice(blockedGames.indexOf(gameName), 1);
else blockedGames.push(gameName);
this.settings.provider.set('directory.game.blocked-games', blockedGames);
this.updateButtons(inst);
});
// Hide / Unhide Thumbnails
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
const thumbnailBlocked = hiddenThumbnails.includes(inst.props.directoryName);
const hideThumbnailButton = e('button', {
className: 'mg-l-1 tw-button ffz-toggle-thumbnail',
style: `background-color: ${thumbnailBlocked ? '#228B22' : '#B22222'};`
}, e('span', {
className: 'tw-button__text',
textContent: `${thumbnailBlocked ? 'Unhide Thumbnails' : 'Hide Thumbnails'}`
})
);
hideThumbnailButton.addEventListener('click', () => {
const gameName = inst.props.directoryName;
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
if (hiddenThumbnails.includes(gameName)) hiddenThumbnails.splice(hiddenThumbnails.indexOf(gameName), 1);
else hiddenThumbnails.push(gameName);
this.settings.provider.set('directory.game.hidden-thumbnails', hiddenThumbnails);
this.updateButtons(inst);
});
const ffzButtons = e('div', 'ffz-buttons', [
blockButton,
hideThumbnailButton
]);
buttons.appendChild(ffzButtons);
}
}
}

View file

@ -0,0 +1,588 @@
'use strict';
// ============================================================================
// Following Page
// ============================================================================
import {SiteModule} from 'utilities/module';
import {createElement as e} from 'utilities/dom';
import {get} from 'utilities/object';
import {duration_to_string} from 'utilities/time';
import Popper from 'popper.js';
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('settings');
this.settings.add('directory.following.group-hosts', {
default: true,
ui: {
path: 'Directory > Following >> Placeholder',
title: 'Group Hosts',
description: 'Only show a given hosted channel once in the directory.',
component: 'setting-check-box'
},
changed: () => this.isRouteAcceptable() && this.apollo.getQuery('FollowedIndex_CurrentUser').refetch()
});
this.settings.add('directory.following.uptime', {
default: 1,
ui: {
path: 'Directory > Following >> Placeholder',
title: 'Stream Uptime',
description: 'Display the stream uptime on the channel cards.',
component: 'setting-select-box',
data: [
{value: 0, title: 'Disabled'},
{value: 1, title: 'Enabled'},
{value: 2, title: 'Enabled (with Seconds)'}
]
},
changed: () => this.isRouteAcceptable() && this.ChannelCard.forceUpdate()
});
this.settings.add('directory.following.host-menus', {
default: 1,
ui: {
path: 'Directory > Following >> Placeholder',
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.isRouteAcceptable() && this.ChannelCard.forceUpdate()
});
this.settings.add('directory.following.hide-boxart', {
default: 0,
ui: {
path: 'Directory > Following >> Placeholder',
title: 'Hide Boxart',
description: 'Do not display boxart over a stream / video thumbnail.',
component: 'setting-select-box',
data: [
{value: 0, title: 'Never'},
{value: 1, title: 'On Hover'},
{value: 2, title: 'Always'}
]
},
changed: value => {
this.css_tweaks.toggleHide('boxart-hide', value === 2);
this.css_tweaks.toggleHide('boxart-hover', value === 1);
if (this.isRouteAcceptable()) this.ChannelCard.forceUpdate()
}
});
this.settings.add('directory.following.show-channel-avatar', {
default: 1,
ui: {
path: 'Directory > Following >> Placeholder',
title: 'Show Channel Avatar',
description: 'Show channel avatar next or on-top of a stream / video thumbnail.',
component: 'setting-select-box',
data: [
{value: 0, title: 'Never'},
{value: 1, title: 'Next to Stream Name'},
{value: 2, title: 'On Thumbnail, Hidden on Hover'},
{value: 3, title: 'On Thumbnail'}
]
},
changed: value => {
this.css_tweaks.toggleHide('profile-hover-following', value === 2);
if (this.isRouteAcceptable()) this.ChannelCard.forceUpdate();
}
});
this.apollo.registerModifier('FollowedIndex_CurrentUser', `query {
currentUser {
followedLiveUsers {
nodes {
profileImageURL(width: 70)
stream {
createdAt
}
}
}
followedHosts {
nodes {
profileImageURL(width: 70)
hosting {
profileImageURL(width: 70)
stream {
createdAt
type
}
}
}
}
}
}`);
this.apollo.registerModifier('FollowingLive_CurrentUser', `query {
currentUser {
followedLiveUsers {
nodes {
profileImageURL(width: 70)
stream {
createdAt
}
}
}
}
}`);
this.apollo.registerModifier('FollowingHosts_CurrentUser', `query {
currentUser {
followedHosts {
nodes {
profileImageURL(width: 70)
hosting {
profileImageURL(width: 70)
stream {
createdAt
}
}
}
}
}
}`);
this.ChannelCard = this.fine.define(
'channel-card',
n => n.renderGameBoxArt && n.renderContentType
);
this.apollo.registerModifier('FollowedIndex_CurrentUser', res => {
res = this.modifyLiveUsers(res);
res = this.modifyLiveHosts(res);
return res;
}, false);
this.apollo.registerModifier('FollowingLive_CurrentUser', res => this.modifyLiveUsers(res), false);
this.apollo.registerModifier('FollowingHosts_CurrentUser', res => this.modifyLiveHosts(res), false);
}
isRouteAcceptable() {
return this.router.current.name === 'dir-following-index'
|| this.router.current.name === 'dir-category' && this.router.match[1] === 'following';
}
modifyLiveUsers(res) { // eslint-disable-line class-methods-use-this
if (!this.isRouteAcceptable()) return res;
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
const newLiveNodes = [];
const nodes = res.data.currentUser.followedLiveUsers.nodes;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const s = node.stream.viewersCount = new Number(node.stream.viewersCount || 0);
s.profileImageURL = node.profileImageURL;
s.createdAt = node.stream.createdAt;
if (node.stream.game && hiddenThumbnails.includes(node.stream.game.name)) node.stream.previewImageURL = 'https://static-cdn.jtvnw.net/ttv-static/404_preview-320x180.jpg';
if (!node.stream.game || node.stream.game && !blockedGames.includes(node.stream.game.name)) newLiveNodes.push(node);
}
res.data.currentUser.followedLiveUsers.nodes = newLiveNodes;
return res;
}
modifyLiveHosts(res) { // eslint-disable-line class-methods-use-this
if (!this.isRouteAcceptable()) return res;
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
const nodes = res.data.currentUser.followedHosts.nodes;
this.hosts = {};
const newHostNodes = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const s = node.hosting.stream.viewersCount = new Number(node.hosting.stream.viewersCount || 0);
s.profileImageURL = node.hosting.profileImageURL;
s.createdAt = node.hosting.stream.createdAt;
s.hostData = {
channel: node.hosting.login,
displayName: node.hosting.displayName
};
if (!this.hosts[node.hosting.displayName]) {
this.hosts[node.hosting.displayName] = {
nodes: [node],
channels: [node.displayName]
};
if (node.hosting.stream.game && hiddenThumbnails.includes(node.hosting.stream.game.name)) node.hosting.stream.previewImageURL = 'https://static-cdn.jtvnw.net/ttv-static/404_preview-320x180.jpg';
if (!node.hosting.stream.game || node.hosting.stream.game && !blockedGames.includes(node.hosting.stream.game.name)) newHostNodes.push(node);
} else {
this.hosts[node.hosting.displayName].nodes.push(node);
this.hosts[node.hosting.displayName].channels.push(node.displayName);
}
}
if (this.settings.get('directory.following.group-hosts')) {
res.data.currentUser.followedHosts.nodes = newHostNodes;
}
return res;
}
onEnable() {
this.css_tweaks.toggleHide('boxart-hover', this.settings.get('directory.following.hide-boxart') === 1);
this.css_tweaks.toggleHide('boxart-hide', this.settings.get('directory.following.hide-boxart') === 2);
this.css_tweaks.toggleHide('profile-hover-following', this.settings.get('directory.following.show-channel-avatar') === 2);
this.ChannelCard.on('update', inst => this.updateChannelCard(inst), this);
this.ChannelCard.ready((cls, instances) => {
if (this.router && this.router.match) {
if (this.router.match[1] === 'following') {
this.apollo.ensureQuery(
'FollowedIndex_CurrentUser',
n =>
get('data.currentUser.followedLiveUsers.nodes.0.profileImageURL', n) !== undefined
||
get('data.currentUser.followedHosts.nodes.0.hosting.profileImageURL', n) !== undefined
);
} else if (this.router.match[1] === 'live') {
this.apollo.ensureQuery(
'FollowingLive_CurrentUser',
'data.currentUser.followedLiveUsers.nodes.0.profileImageURL'
);
} else if (this.router.match[1] === 'hosts') {
this.apollo.ensureQuery(
'FollowingHosts_CurrentUser',
'data.currentUser.followedHosts.nodes.0.hosting.profileImageURL'
);
}
}
for(const inst of instances) this.updateChannelCard(inst);
});
this.ChannelCard.on('mount', inst => this.updateChannelCard(inst), this);
this.ChannelCard.on('unmount', inst => this.updateUptime(inst), this);
document.body.addEventListener('click', this.destroyHostMenu.bind(this));
}
destroyHostMenu(event) {
if (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;
}
}
updateUptime(inst) {
const container = this.fine.getHostNode(inst);
const card = container && container.querySelector && container.querySelector('.tw-card .tw-aspect > div');
// if (container === null || card === null) {
// if (inst.updateTimer !== undefined) {
// clearInterval(inst.updateTimer);
// inst.updateTimer = undefined;
// return;
// }
// }
if (this.settings.get('directory.following.uptime') === 0) {
if (inst.updateTimer !== undefined) {
clearInterval(inst.updateTimer);
inst.updateTimer = undefined;
}
if (inst.uptimeElement !== undefined) {
inst.uptimeElement.remove();
inst.uptimeElementSpan = inst.uptimeElement = undefined;
}
} else {
if (inst.updateTimer === undefined) {
inst.updateTimer = setInterval(
this.updateUptime.bind(this, inst),
1000
);
}
const up_since = new Date(inst.props.viewerCount.createdAt);
const uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
const uptimeText = duration_to_string(uptime, false, false, false, this.settings.get('directory.following.uptime') === 1);
if (uptime > 0) {
if (inst.uptimeElement === undefined) {
inst.uptimeElementSpan = e('span', 'tw-stat__value ffz-uptime', `${uptimeText}`);
inst.uptimeElement = e('div', {
className: 'c-background-overlay c-text-overlay font-size-6 top-0 right-0 z-default inline-flex absolute mg-05',
style: 'padding-left: 4px; padding-right: 4px;'
}, [
e('span', 'tw-stat__icon',
e('figure', 'ffz-i-clock')
),
inst.uptimeElementSpan
]);
if (card.querySelector('.ffz-uptime') === null) card.appendChild(inst.uptimeElement);
} else {
inst.uptimeElementSpan.textContent = `${uptimeText}`;
}
}
}
}
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(
e('p', {
className: 'pd-t-05 pd-x-1 c-text-alt-2',
textContent: 'Hosted Channel'
})
);
// Hosted Channel Content
simplebarContentChildren.push(
e('a', {
className: 'tw-interactable',
href: inst.props.linkTo.pathname,
style: 'padding-top: 0.1rem; padding-bottom: 0.1rem;',
onclick: event => {
event.preventDefault();
event.stopPropagation();
this.router.navigate('user', { userName: inst.props.linkTo.pathname.substring(1)});
}
}, e('div', 'align-items-center flex flex-row flex-nowrap mg-x-1 mg-y-05',
[
e('div', {
className: 'flex-shrink-0',
style: 'overflow: hidden; width: 3rem; height: 3rem;',
}, e('img', {
src: inst.props.viewerCount.profileImageURL,
alt: inst.props.channelName
})),
e('p', {
className: 'ellipsis flex-grow-1 mg-l-1 font-size-5',
textContent: inst.props.channelName
})
]
))
);
// Hosting Channels Header
simplebarContentChildren.push(
e('p', {
className: 'pd-t-05 pd-x-1 c-text-alt-2',
textContent: 'Hosting Channels'
})
);
// Hosting Channels Content
const hosts = this.hosts[inst.props.channelName];
for (let i = 0; i < hosts.nodes.length; i++) {
const node = hosts.nodes[i];
simplebarContentChildren.push(
e('a', {
className: 'tw-interactable',
href: `/${node.login}`,
style: 'padding-top: 0.1rem; padding-bottom: 0.1rem;',
}, e('div', 'align-items-center flex flex-row flex-nowrap mg-x-1 mg-y-05',
[
e('div', {
className: 'flex-shrink-0',
style: 'overflow: hidden; width: 3rem; height: 3rem;',
}, e('img', {
src: node.profileImageURL,
alt: node.displayName
})),
e('p', {
className: 'ellipsis flex-grow-1 mg-l-1 font-size-5',
textContent: node.displayName
})
]
))
);
}
this.hostMenu = e('div', 'tw-balloon block',
e('div', 'selectable-filter__balloon pd-y-05',
e('div', {
className: 'scrollable-area',
style: 'max-height: 20rem;',
'data-simplebar': true,
}, [
e('div', 'simplebar-track vertical',
e('div', 'simplebar-scrollbar')
),
e('div', 'simplebar-scroll-content',
e('div', 'simplebar-content', simplebarContentChildren)
)
])
)
);
document.body.appendChild(this.hostMenu);
this.hostMenuPopper = new Popper(document.body, this.hostMenu, {
placement: 'bottom-start',
modifiers: {
flip: {
enabled: false
},
offset: {
offset: `${event.clientX - 60}, ${event.clientY - 60}`
}
},
});
this.hostMenuBuffer = Date.now() + 50;
}
updateChannelCard(inst) {
this.updateUptime(inst);
const container = this.fine.getHostNode(inst);
const card = container && container.querySelector && container.querySelector('.tw-card');
if (container === null || card === null) return;
const channelCardTitle = card.querySelector('.live-channel-card__title');
if (channelCardTitle === null) return;
// Remove old elements
const hiddenBodyCard = card.querySelector('.tw-card-body.hide');
if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('hide');
const ffzChannelData = card.querySelector('.ffz-channel-data');
if (ffzChannelData !== null) ffzChannelData.remove();
const channelAvatar = card.querySelector('.channel-avatar');
if (channelAvatar !== null) channelAvatar.remove();
if (inst.props.viewerCount.profileImageURL) {
const hosting = inst.props.viewerCount.hostData;
let channel, displayName;
if (hosting) {
channel = inst.props.viewerCount.hostData.channel;
displayName = inst.props.viewerCount.hostData.displayName;
}
const avatarSetting = this.settings.get('directory.following.show-channel-avatar');
const cardDiv = card.querySelector('.tw-card-body');
const modifiedDiv = e('div', {
innerHTML: cardDiv.innerHTML
});
let avatarDiv;
if (avatarSetting === 1) {
avatarDiv = e('a', {
className: 'channel-avatar',
href: hosting ? `/${channel}` : inst.props.linkTo.pathname,
style: 'margin-right: 8px; min-width: 4rem; margin-top: 0.5rem;'
}, e('img', {
title: inst.props.channelName,
src: inst.props.viewerCount.profileImageURL,
style: 'height: 4rem;'
}));
} else if (avatarSetting === 2 || avatarSetting === 3) {
const avatarElement = e('a', {
className: 'channel-avatar',
href: hosting ? `/${channel}` : inst.props.linkTo.pathname,
onclick: event => {
event.preventDefault();
event.stopPropagation();
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
}
}, e('div', 'live-channel-card__boxart bottom-0 absolute',
e('figure', 'tw-aspect tw-aspect--align-top',
e('img', {
title: inst.props.channelName,
src: inst.props.viewerCount.profileImageURL
})
)
)
);
const divToAppend = card.querySelector('.tw-aspect > div');
if (divToAppend.querySelector('.channel-avatar') === null) divToAppend.appendChild(avatarElement);
}
const cardDivParent = cardDiv.parentElement;
const ffzChannelData = cardDivParent.querySelector('.ffz-channel-data');
if (ffzChannelData === null) {
cardDiv.classList.add('hide');
const newCardDiv = e('div', 'ffz-channel-data flex flex-nowrap', [
avatarDiv, modifiedDiv
]);
cardDivParent.appendChild(newCardDiv);
}
if (hosting) {
const hostObj = this.hosts[displayName];
if (this.settings.get('directory.following.group-hosts')) {
const titleLink = card.querySelector('.ffz-channel-data a[data-a-target="live-channel-card-title-link"]');
const thumbnailLink = card.querySelector('a[data-a-target="live-channel-card-thumbnail-link"]');
if (hostObj.channels.length > 1) {
const textContent = `${hostObj.channels.length} hosting ${displayName}`;
channelCardTitle.textContent
= channelCardTitle.title
= textContent;
if (thumbnailLink !== null) thumbnailLink.title = textContent;
}
if (titleLink !== null) titleLink.onclick = this.showHostMenu.bind(this, inst, hostObj);
if (thumbnailLink !== null) thumbnailLink.onclick = this.showHostMenu.bind(this, inst, hostObj);
}
}
}
}
}

View file

@ -0,0 +1,321 @@
'use strict';
// ============================================================================
// Directory (Following, for now)
// ============================================================================
import {SiteModule} from 'utilities/module';
import {createElement as e} from 'utilities/dom';
import {duration_to_string} from 'utilities/time';
export default class Game 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('settings');
this.GameHeader = this.fine.define(
'game-header',
n => n.renderFollowButton && n.renderGameDetailsTab
);
this.apollo.registerModifier('GamePage_Game', `query {
directory {
... on Game {
streams {
edges {
node {
createdAt
type
broadcaster {
profileImageURL(width: 70)
}
}
}
}
}
}
}`);
this.ChannelCard = this.fine.define(
'game-channel-card',
n => n.props && n.props.streamNode
);
this.apollo.registerModifier('GamePage_Game', res => this.router.current.name === 'dir-game-index' && this.modifyStreams(res), false);
this.on('site.directory.following:update-show-channel-avatar', value => {
this.css_tweaks.toggleHide('profile-hover-game', value === 2);
this.router.current.name === 'dir-game-index' && this.ChannelCard.forceUpdate();
});
this.on('site.directory.following:update-uptime', () => this.router.current.name === 'dir-game-index' && this.ChannelCard.forceUpdate());
}
modifyStreams(res) { // eslint-disable-line class-methods-use-this
const newStreams = [];
// const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
// const thumbnailBlocked = hiddenThumbnails.includes(res.data.directory.displayName);
const edges = res.data.directory.streams.edges;
for (let i = 0; i < edges.length; i++) {
const edge = edges[i];
const node = edge.node;
const s = node.viewersCount = new Number(node.viewersCount || 0);
s.profileImageURL = node.broadcaster.profileImageURL;
s.createdAt = node.createdAt;
// if (thumbnailBlocked) edge.node.previewImageURL = 'https://static-cdn.jtvnw.net/ttv-static/404_preview-320x180.jpg';
newStreams.push(edge);
}
res.data.directory.streams.edges = newStreams;
return res;
}
onEnable() {
this.GameHeader.ready((cls, instances) => {
if (this.router.current.name === 'dir-game-index') {
for(const inst of instances) this.updateButtons(inst);
}
});
this.ChannelCard.ready((cls, instances) => {
if (this.router.current.name === 'dir-game-index') {
this.apollo.ensureQuery(
'GamePage_Game',
'data.directory.streams.edges.0.node.createdAt'
);
for(const inst of instances) this.updateChannelCard(inst);
}
});
this.ChannelCard.on('update', inst => this.router.current.name === 'dir-game-index' && this.updateChannelCard(inst), this);
this.ChannelCard.on('mount', inst => this.router.current.name === 'dir-game-index' && this.updateChannelCard(inst), this);
this.ChannelCard.on('unmount', inst => this.router.current.name === 'dir-game-index' && this.updateUptime(inst), this);
this.css_tweaks.toggleHide('profile-hover-game', this.settings.get('directory.following.show-channel-avatar') === 2);
}
updateUptime(inst) {
const container = this.fine.getHostNode(inst);
const card = container && container.querySelector && container.querySelector('.tw-thumbnail-card');
if (container === null || card === null) {
if (inst.updateTimer !== undefined) {
clearInterval(inst.updateTimer);
inst.updateTimer = undefined;
return;
}
}
if (this.settings.get('directory.following.uptime') === 0) {
if (inst.updateTimer !== undefined) {
clearInterval(inst.updateTimer);
inst.updateTimer = undefined;
}
if (inst.uptimeElement !== undefined) {
inst.uptimeElement.remove();
inst.uptimeElementSpan = inst.uptimeElement = undefined;
}
} else {
if (inst.updateTimer === undefined) {
inst.updateTimer = setInterval(
this.updateUptime.bind(this, inst),
1000
);
}
const up_since = new Date(inst.props.streamNode.viewersCount.createdAt);
const uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
const uptimeText = duration_to_string(uptime, false, false, false, this.settings.get('directory.following.uptime') === 1);
if (uptime > 0) {
if (inst.uptimeElement === undefined) {
inst.uptimeElementSpan = e('span', 'tw-stat__value ffz-uptime', `${uptimeText}`);
inst.uptimeElement = e('div', {
className: 'c-background-overlay c-text-overlay font-size-6 top-0 right-0 z-default inline-flex absolute mg-05',
style: 'padding-left: 4px; padding-right: 4px;'
}, [
e('span', 'tw-stat__icon',
e('figure', 'ffz-i-clock')
),
inst.uptimeElementSpan
]);
if (card.querySelector('.ffz-uptime') === null) card.appendChild(inst.uptimeElement);
} else {
inst.uptimeElementSpan.textContent = `${uptimeText}`;
}
}
}
}
updateChannelCard(inst) {
this.updateUptime(inst);
const container = this.fine.getHostNode(inst);
const card = container && container.querySelector && container.querySelector('.tw-thumbnail-card');
if (container === null || card === null) return;
if (!inst.props.streamNode.viewersCount.createdAt || container === null || card === null) return;
// Remove old elements
const hiddenBodyCard = card.querySelector('.tw-card-body.hide');
if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('hide');
const ffzChannelData = card.querySelector('.ffz-channel-data');
if (ffzChannelData !== null) ffzChannelData.remove();
const channelAvatar = card.querySelector('.channel-avatar');
if (channelAvatar !== null) channelAvatar.remove();
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
const thumbnailBlocked = hiddenThumbnails.includes(inst.props.directoryName);
this.log.warn(inst);
if (thumbnailBlocked) {
card.classList.add('ffz-thumbnail-hidden');
} else {
card.classList.remove('ffz-thumbnail-hidden');
}
if (inst.props.streamNode.viewersCount.profileImageURL) {
const avatarSetting = this.settings.get('directory.following.show-channel-avatar');
if (avatarSetting === 1) {
const cardDiv = card.querySelector('.tw-card-body');
const modifiedDiv = e('div', {
innerHTML: cardDiv.innerHTML
});
const avatarDiv = e('a', {
className: 'channel-avatar',
href: `/${inst.props.streamNode.broadcaster.login}`,
style: 'margin-right: 8px; min-width: 4rem; margin-top: 0.5rem;',
onclick: event => {
event.preventDefault();
event.stopPropagation();
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
}
}, e('img', {
title: inst.props.streamNode.broadcaster.displayName,
src: inst.props.streamNode.viewersCount.profileImageURL,
style: 'height: 4rem;'
}));
const cardDivParent = cardDiv.parentElement;
if (cardDivParent.querySelector('.ffz-channel-data') === null) {
cardDiv.classList.add('hide');
const newCardDiv = e('div', 'ffz-channel-data flex flex-nowrap', [
avatarDiv, modifiedDiv
]);
cardDivParent.appendChild(newCardDiv);
}
} else if (avatarSetting === 2 || avatarSetting === 3) {
const avatarElement = e('a', {
className: 'channel-avatar',
href: `/${inst.props.streamNode.broadcaster.login}`,
onclick: event => {
event.preventDefault();
event.stopPropagation();
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
}
}, e('div', 'live-channel-card__boxart bottom-0 absolute',
e('figure', 'tw-aspect tw-aspect--align-top',
e('img', {
title: inst.props.streamNode.broadcaster.displayName,
src: inst.props.streamNode.viewersCount.profileImageURL
})
)
)
);
const divToAppend = card.querySelector('figure.tw-aspect');
if (divToAppend.querySelector('.channel-avatar') === null) divToAppend.appendChild(avatarElement);
}
}
}
updateButtons(inst) {
const container = this.fine.getHostNode(inst);
// We can't get the buttons through querySelector('button ...') so this has to do for now...
const buttons = container && container.querySelector && container.querySelector('div > div.align-items-center');
const ffzButtons = buttons.querySelector('.ffz-buttons');
if (ffzButtons !== null) ffzButtons.remove();
if (buttons.querySelector('.ffz-buttons') === null) {
// Block / Unblock Games
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
const gameBlocked = blockedGames.includes(inst.props.directoryName);
const blockButton = e('button', {
className: 'mg-l-1 tw-button ffz-toggle-game-block',
style: `background-color: ${gameBlocked ? '#228B22' : '#B22222'};`
}, e('span', {
className: 'tw-button__text',
textContent: `${gameBlocked ? 'Unblock' : 'Block'}`
})
);
blockButton.addEventListener('click', () => {
const gameName = inst.props.directoryName;
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
if (blockedGames.includes(gameName)) blockedGames.splice(blockedGames.indexOf(gameName), 1);
else blockedGames.push(gameName);
this.settings.provider.set('directory.game.blocked-games', blockedGames);
this.updateButtons(inst);
});
// Hide / Unhide Thumbnails
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
const thumbnailBlocked = hiddenThumbnails.includes(inst.props.directoryName);
const hideThumbnailButton = e('button', {
className: 'mg-l-1 tw-button ffz-toggle-thumbnail',
style: `background-color: ${thumbnailBlocked ? '#228B22' : '#B22222'};`
}, e('span', {
className: 'tw-button__text',
textContent: `${thumbnailBlocked ? 'Unhide Thumbnails' : 'Hide Thumbnails'}`
})
);
hideThumbnailButton.addEventListener('click', () => {
const gameName = inst.props.directoryName;
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
if (hiddenThumbnails.includes(gameName)) hiddenThumbnails.splice(hiddenThumbnails.indexOf(gameName), 1);
else hiddenThumbnails.push(gameName);
this.settings.provider.set('directory.game.hidden-thumbnails', hiddenThumbnails);
this.updateButtons(inst);
this.ChannelCard.forceUpdate();
});
const ffzButtons = e('div', 'ffz-buttons', [
blockButton,
hideThumbnailButton
]);
buttons.appendChild(ffzButtons);
}
}
}

View file

@ -0,0 +1,23 @@
'use strict';
// ============================================================================
// Directory
// ============================================================================
import {SiteModule} from 'utilities/module';
import Following from './following';
import Game from './game';
import Community from './community';
export default class Directory extends SiteModule {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject(Following);
this.inject(Game);
this.inject(Community);
}
}

View file

@ -0,0 +1,240 @@
'use strict';
// ============================================================================
// Following Button Modification
// ============================================================================
import {SiteModule} from 'utilities/module';
import {createElement as e} from 'utilities/dom';
import {duration_to_string} from 'utilities/time';
import Tooltip from 'utilities/tooltip';
export default class FollowingText extends SiteModule {
constructor(...args) {
super(...args);
this.should_enable = true;
// this.inject('site');
this.inject('settings');
this.inject('site.router');
this.inject('site.apollo');
this.inject('i18n');
this.apollo.registerModifier('FollowedChannels', `query {
currentUser {
followedLiveUsers {
nodes {
profileImageURL(width: 70)
stream {
type
createdAt
}
}
}
}
}`);
this.apollo.registerModifier('FollowedChannels', res => {
this.updateFollowing(this.nodes = res.data.currentUser.followedLiveUsers.nodes);
}, false);
this.router.on(':route', () => {
this.updateFollowing();
});
}
async updateFollowing(nodes) {
nodes = nodes || this.nodes || [];
const followingText = await this.site.awaitElement('.top-nav__nav-link[data-a-target="following-link"]');
const topNavContainer = followingText.parentElement;
const oldFFZFollowingText = topNavContainer.querySelector('.ffz-following-container');
if (oldFFZFollowingText !== null) oldFFZFollowingText.remove();
const topSpan = e('span', {
className: 'top-span',
style: 'width: 100%; float: left; text-align: left; border-bottom: 1px black solid; padding-bottom: 8px;',
textContent: 'Following'
});
const height = document.body.querySelector('.twilight-root').clientHeight - 50;
const max_lines = Math.max(Math.floor(height / 40) - 1, 2);
let c = 0;
let filtered = 0;
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
const innerContent = [topSpan, e('br')];
if (nodes.length) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (blockedGames.includes(node.stream.game.name)) {
filtered += 1;
continue;
}
c += 1;
if (c > max_lines) {
const div = e('div', {
className: 'ffz-following-inner',
style: 'padding-top: 16px; padding-bottom: 4px;'
}, e('span', {
textContent: `And ${this.i18n.formatNumber(nodes.length - max_lines)} more${filtered ? ` (${filtered} hidden)` : ''}`,
style: 'float: left; margin-bottom: 8px;'
}));
innerContent.push(div);
break;
}
const up_since = new Date(node.stream.createdAt);
const uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
const uptimeText = duration_to_string(uptime, false, false, false, true);
const div = e('div', {
className: 'ffz-following-inner',
style: 'padding-top: 16px; padding-bottom: 4px;'
}, [
e('div', {
className: 'top-stream-info',
style: 'padding-bottom: 16px;'
}, [
// Username
e('a', {
class: 'top-nav__nav-link',
textContent: node.displayName,
style: 'float: left; font-weight: bold; padding: unset; font-size: unset;',
href: `/${node.login}`,
onclick: event => {
event.preventDefault();
event.stopPropagation();
this.router.navigate('user', { userName: node.login });
}
}),
e('div', {
style: 'float: right;'
}, [
// Uptime
e('div', {
className: 'ffz-uptime-cont',
style: 'float: right;'
}, [
e('span', 'tw-stat__icon',
e('figure', 'ffz-i-clock')
),
e('span', {
textContent: uptimeText
})
]),
// Viewers
e('div', {
className: 'ffz-viewer-cont',
style: 'float: right; margin-right: 4px;'
}, [
e('span', 'tw-stat__icon',
e('figure', 'ffz-i-plus')
),
e('span', {
textContent: `${this.i18n.formatNumber(node.stream.viewersCount)}`
})
])
])
]),
e('span', {
className: 'ellipsis',
textContent: `Playing ${node.stream.game.name}`,
style: 'float: left; margin-bottom: 8px; max-width: 100%;'
})
]);
innerContent.push(div);
}
if (filtered) {
const div = e('div', {
className: 'ffz-following-inner',
style: 'padding-top: 16px; padding-bottom: 4px;'
}, e('span', {
textContent: `(${filtered} hidden)`,
style: 'float: left; margin-bottom: 8px;'
}));
innerContent.push(div);
}
} else {
const div = e('div', {
className: 'ffz-following-inner',
style: 'padding-top: 16px; padding-bottom: 4px;'
}, e('span', {
textContent: `No one you're following is online.`,
style: 'float: left; margin-bottom: 8px;'
}));
innerContent.push(div);
}
const content = e('div', {
style: 'padding: 4px;',
}, innerContent);
const tipDiv = e('div', {
className: 'ffz-following',
tip_content: content,
});
const newFollowing = e('div', {
className: 'top-nav__nav-link ffz-following-container',
style: 'padding: 1.5rem 1.5rem 0 0;'
}, [
e('a', {
class: 'top-nav__nav-link',
href: '/directory/following',
textContent: followingText.title,
onclick: event => {
event.preventDefault();
event.stopPropagation();
this.router.navigate('dir-following');
}
}),
e('span', {
className: 'tw-pill tw-pill--brand',
textContent: nodes.length,
style: 'margin-left: -0.5rem;'
}),
tipDiv
]);
topNavContainer.insertBefore(newFollowing, followingText);
followingText.classList.add('hide');
newFollowing.tooltip = new Tooltip(tipDiv, newFollowing, {
live: false,
html: true,
interactive: true,
delayHide: 250,
content: () => tipDiv.tip_content,
onShow: (t, tip) => tipDiv.tip = tip,
onHide: () => tipDiv.tip = null,
popper: {
placement: 'bottom',
modifiers: {
backgroundChange: {
order: 900 - 1,
enabled: true,
fn: data => {
data.styles.width = '300px';
return data;
}
}
}
}
});
}
async onEnable() {
await this.site.awaitElement('.top-nav__nav-link[data-a-target="following-link"]');
this.apollo.ensureQuery('FollowedChannels', 'data.currentUser.followedLiveUsers.nodes.0.stream.createdAt');
}
}

View file

@ -18,81 +18,12 @@ export default class Apollo extends Module {
this.inject('..web_munch');
this.inject('..fine');
this.registerModifier('FollowedIndex_CurrentUser', `query {
currentUser {
followedLiveUsers {
nodes {
profileImageURL(width: 70)
stream {
createdAt
}
}
}
followedHosts {
nodes {
hosting {
profileImageURL(width: 70)
stream {
createdAt
type
}
}
}
}
}
}`);
this.registerModifier('FollowingLive_CurrentUser', `query {
currentUser {
followedLiveUsers {
nodes {
profileImageURL(width: 70)
stream {
createdAt
}
}
}
}
}`);
this.registerModifier('ViewerCard', `query {
targetUser: user {
createdAt
profileViewCount
}
}`);
/*this.registerModifier('GamePage_Game', `query {
directory {
... on Community {
streams {
edges {
node {
createdAt
type
broadcaster {
profileImageURL(width: 70)
}
}
}
}
}
... on Game {
streams {
edges {
node {
createdAt
type
broadcaster {
profileImageURL(width: 70)
}
}
}
}
}
}
}`);*/
}`);
}
async onEnable() {
@ -225,9 +156,9 @@ export default class Apollo extends Module {
modifier = [modifier, parsed];
}
const mods = pre ?
(this.modifiers[operation] = this.modifiers[operation] || []) :
(this.post_modifiers[operation] = this.post_modifiers[operation] || []);
const mods = pre
? (this.modifiers[operation] = this.modifiers[operation] || [])
: (this.post_modifiers[operation] = this.post_modifiers[operation] || []);
mods.push(modifier);
}
@ -352,12 +283,19 @@ function merge(a, b) {
if ( a.selectionSet ) {
const s = a.selectionSet.selections,
selects = {};
for(const sel of b.selectionSet.selections)
for(const sel of b.selectionSet.selections) {
if (sel.name && sel.name.value) {
selects[`${sel.name.value}:${sel.alias?sel.alias.value:null}`] = sel;
} else {
if (sel.kind === 'InlineFragment') {
selects[`${sel.typeCondition.name.value}:${sel.alias?sel.alias.value:null}`] = sel;
}
}
}
for(let i=0, l = s.length; i < l; i++) {
const sel = s[i],
name = sel.name.value,
name = sel.kind === 'InlineFragment' ? (sel.typeCondition.name ? sel.typeCondition.name.value : null) : (sel.name ? sel.name.value : null),
alias = sel.alias ? sel.alias.value : null,
key = `${name}:${alias}`,
other = selects[key];