diff --git a/src/sites/twitch-twilight/modules/featured-follow.vue b/src/sites/twitch-twilight/modules/featured-follow.vue
new file mode 100644
index 00000000..3e574acc
--- /dev/null
+++ b/src/sites/twitch-twilight/modules/featured-follow.vue
@@ -0,0 +1,77 @@
+
+
+
+ {{ t('metadata.featured-follow.title', 'Featured Follow') }}
+
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
{{ user.displayName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/sites/twitch-twilight/modules/featured_follow.js b/src/sites/twitch-twilight/modules/featured_follow.js
new file mode 100644
index 00000000..dd2cd103
--- /dev/null
+++ b/src/sites/twitch-twilight/modules/featured_follow.js
@@ -0,0 +1,213 @@
+'use strict';
+
+// ============================================================================
+// Featured Follow
+// ============================================================================
+
+import Module from 'utilities/module';
+import {createElement as e} from 'utilities/dom';
+
+import FEATURED_QUERY from './featured_follow_query.gql';
+
+import FEATURED_FOLLOW from './featured_follow_follow.gql';
+import FEATURED_UNFOLLOW from './featured_follow_unfollow.gql';
+
+const TWITCH_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([A-Za-z0-9_]+)/i;
+
+export default class FeaturedFollow extends Module {
+ constructor(...args) {
+ super(...args);
+
+ this.should_enable = true;
+
+ this.inject('site');
+ this.inject('site.fine');
+ this.inject('site.apollo');
+ this.inject('i18n');
+ this.inject('metadata');
+ this.inject('settings');
+ this.inject('socket');
+ this.inject('site.router');
+
+ this.inject('chat');
+
+ this.settings.add('metadata.featured-follow', {
+ default: true,
+
+ ui: {
+ path: 'Channel > Metadata >> Player',
+ title: 'Featured Follow',
+ description: 'Show a featured follow button with the currently featured users.',
+ component: 'setting-check-box'
+ },
+
+ changed: () => {
+ this.metadata.updateMetadata('following');
+ }
+ });
+
+ this.metadata.definitions.following = {
+ order: 150,
+ button: true,
+
+ popup: async (data, tip) => {
+ const vue = this.resolve('vue'),
+ _featured_follow_vue = import(/* webpackChunkName: "featured-follow" */ './featured-follow.vue'),
+ _follows = this.getFollowsForLogin(data.channel.login);
+
+ const [, featured_follows_vue, follows] = await Promise.all([vue.enable(), _featured_follow_vue, _follows]);
+
+ this._featured_follow_tip = tip;
+ tip.element.classList.remove('tw-pd-1');
+ tip.element.classList.add('tw-balloon--lg');
+ vue.component('featured-follow', featured_follows_vue.default);
+ return this.buildFeaturedFollowMenu(vue, data.channel.login, follows);
+ },
+
+ label: data => {
+ if (!this.settings.get('metadata.featured-follow') || !data || !data.channel || !data.channel.login)
+ return null;
+
+ const follows = this.follow_data[data.channel.login];
+ if (!follows || !Object.keys(follows).length) {
+ if (!this.vueFeaturedFollow || !this.vueFeaturedFollow.data.hasUpdate) {
+ return '';
+ }
+ }
+
+ return this.i18n.t('metadata.featured-follow.button.featured', 'Featured');
+ },
+
+ icon: 'ffz-i-heart'
+ };
+
+ this.follow_data = {};
+
+ this.socket.on(':command:follow_buttons', data => {
+ for(const channel_login in data) {
+ if (!data.hasOwnProperty(channel_login)) continue;
+
+ this.follow_data[channel_login] = data[channel_login];
+ }
+
+ if (this.vueFeaturedFollow) {
+ this.vueFeaturedFollow.data.hasUpdate = true;
+ }
+
+ this.metadata.updateMetadata('following');
+ });
+
+ // ffz.resolve('site.featured_follow').updateFeaturedChannels({ login: 'lordmau5' }, ['sirstendec','jugachi']);
+ }
+
+ async getFollowsForLogin(login) {
+ const follow_data = this.follow_data && this.follow_data[login];
+ if (!follow_data || follow_data.length === 0) return [];
+
+ const ap_data = await this.apollo.client.query({ query: FEATURED_QUERY, variables: { logins: follow_data }});
+
+ const follows = {};
+ for (const user of ap_data.data.users) {
+ follows[user.id] = {
+ id: user.id,
+ login: user.login,
+ displayName: user.displayName,
+ avatar: user.profileImageURL,
+ following: user.self.follower.followedAt != null,
+ disableNotifications: user.self.follower.disableNotifications
+ };
+ }
+ return follows;
+ }
+
+ buildFeaturedFollowMenu(vue, login, follows) {
+ const vueEl = new vue.Vue({
+ el: e('div'),
+ render: h => this.vueFeaturedFollow = h('featured-follow', {
+ login,
+ follows,
+ hasUpdate: false,
+
+ followUser: id => this.followUser(follows, id),
+ unfollowUser: id => this.unfollowUser(follows, id),
+ updateNotificationStatus: (id, oldStatus) => this.updateNotificationStatus(follows, id, oldStatus),
+ refresh: async () => {
+ if (!this.vueFeaturedFollow.data.hasUpdate) return;
+
+ this.vueFeaturedFollow.data.follows = await this.getFollowsForLogin(login);
+ this.vueFeaturedFollow.data.hasUpdate = false;
+ this._featured_follow_tip.update();
+
+ if (this.vueFeaturedFollow.data.follows.length === 0) this.metadata.updateMetadata('following');
+ },
+ route: channel => this.route(channel)
+ }),
+ });
+
+ return vueEl.$el;
+ }
+
+ updateFeaturedChannels(room, args) {
+ args = args.join(' ').trim().toLowerCase().split(/[ ,]+/);
+
+ const out = [];
+
+ for (let i = 0; i < args.length; i++) {
+ let arg = args[i];
+ const match = arg.match(TWITCH_URL);
+
+ if (match)
+ arg = match[1];
+
+ if (arg !== '' && out.indexOf(arg) === -1)
+ out.push(arg);
+ }
+
+ this.socket.call('update_follow_buttons', room.login, out)
+ .then(() => {
+ // this.log.info('Success!', data);
+ })
+ .catch(() => 'There was an error communicating with the server.');
+ // , (success, data) => {
+ // if (!success) {
+ // this.log.warn('Not a Success: ', data);
+ // // f.room_message(room, data);
+ // return;
+ // }
+
+ // this.log.info('Success!', data);
+
+ // // this.room.message(`The following buttons have been ${data ? 'updated' : 'disabled'}.`);
+ // }) )
+
+ }
+
+ async followUser(follows, id) {
+ const ap_data = await this.apollo.client.mutate({ mutation: FEATURED_FOLLOW, variables: { targetID: id, disableNotifications: false }});
+
+ const follow = ap_data.data.followUser.follow;
+
+ follows[id].following = follow.followedAt != null;
+ follows[id].disableNotifications = follow.disableNotifications;
+ }
+
+ async updateNotificationStatus(follows, id, oldStatus) {
+ const ap_data = await this.apollo.client.mutate({ mutation: FEATURED_FOLLOW, variables: { targetID: id, disableNotifications: !oldStatus }});
+
+ const follow = ap_data.data.followUser.follow;
+
+ follows[id].following = follow.followedAt != null;
+ follows[id].disableNotifications = follow.disableNotifications;
+ }
+
+ async unfollowUser(follows, id) {
+ await this.apollo.client.mutate({ mutation: FEATURED_UNFOLLOW, variables: { targetID: id }});
+
+ follows[id].following = false;
+ follows[id].disableNotifications = false;
+ }
+
+ route(channel) {
+ this.router.navigate('user', { userName: channel });
+ }
+}
diff --git a/src/sites/twitch-twilight/modules/featured_follow_follow.gql b/src/sites/twitch-twilight/modules/featured_follow_follow.gql
new file mode 100644
index 00000000..730f726c
--- /dev/null
+++ b/src/sites/twitch-twilight/modules/featured_follow_follow.gql
@@ -0,0 +1,11 @@
+mutation($targetID: ID!, $disableNotifications: Boolean!) {
+ followUser(input: {
+ disableNotifications: $disableNotifications,
+ targetID: $targetID
+ }) {
+ follow {
+ disableNotifications
+ followedAt
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/modules/featured_follow_query.gql b/src/sites/twitch-twilight/modules/featured_follow_query.gql
new file mode 100644
index 00000000..a5781648
--- /dev/null
+++ b/src/sites/twitch-twilight/modules/featured_follow_query.gql
@@ -0,0 +1,14 @@
+query($logins: [String!]) {
+ users(logins: $logins) {
+ id
+ login
+ displayName
+ profileImageURL(width: 70)
+ self {
+ follower {
+ disableNotifications
+ followedAt
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/modules/featured_follow_unfollow.gql b/src/sites/twitch-twilight/modules/featured_follow_unfollow.gql
new file mode 100644
index 00000000..fc0e34a5
--- /dev/null
+++ b/src/sites/twitch-twilight/modules/featured_follow_unfollow.gql
@@ -0,0 +1,9 @@
+mutation($targetID: ID!) {
+ unfollowUser(input: {
+ targetID: $targetID
+ }) {
+ follow {
+ followedAt
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/sites/twitch-twilight/styles/featured_follow.scss b/src/sites/twitch-twilight/styles/featured_follow.scss
new file mode 100644
index 00000000..a616c056
--- /dev/null
+++ b/src/sites/twitch-twilight/styles/featured_follow.scss
@@ -0,0 +1,34 @@
+.ffz-featured-follow {
+ .scrollable-area {
+ max-height: 50vh;
+ }
+
+ .ffz--featured-button-unfollow {
+ &:hover &:before {
+ content: '\e813';
+ }
+
+ > * {
+ pointer-events: none;
+ }
+ }
+
+ .ffz--featured-button-notification {
+ > * {
+ pointer-events: none;
+ }
+ }
+
+ .ffz--featured-follow-update {
+ animation: ffz--pulse-update 1s infinite ease-in-out alternate;
+ }
+
+ > header {
+ padding: .9rem 1rem .9rem 2rem;
+ }
+}
+
+@keyframes ffz--pulse-update {
+ from { box-shadow: none; }
+ to { box-shadow: 0 0 10px 0 var(--ffz-color-5); }
+}
diff --git a/src/sites/twitch-twilight/styles/main.scss b/src/sites/twitch-twilight/styles/main.scss
index 9d5ce81e..8bfcb811 100644
--- a/src/sites/twitch-twilight/styles/main.scss
+++ b/src/sites/twitch-twilight/styles/main.scss
@@ -10,4 +10,5 @@
@import 'fixes';
-@import 'host_options';
\ No newline at end of file
+@import 'host_options';
+@import 'featured_follow';
\ No newline at end of file
diff --git a/src/socket.js b/src/socket.js
index 16155c63..e68f546e 100644
--- a/src/socket.js
+++ b/src/socket.js
@@ -70,7 +70,14 @@ export default class SocketClient extends Module {
this.on(':command:reconnect', this.reconnect, this);
this.on(':command:do_authorize', challenge => {
- this.log.warn('Unimplemented: do_authorize', challenge);
+ // this.log.warn('Unimplemented: do_authorize', challenge);
+ // We don't have our own IRC connection yet, so the site's chat has to do.
+
+ const _chat = this.resolve('site.chat');
+ const chat = _chat && _chat.currentChat;
+ const con = chat.chatService && chat.chatService.client && chat.chatService.client.connection;
+
+ if (con && con.send) con.send(`PRIVMSG #frankerfacezauthorizer :AUTH ${challenge}`);
});