From ddfcf9e17cd0e7e7f1eba7bbc1daf65c3e1b3513 Mon Sep 17 00:00:00 2001 From: SirStendec Date: Sat, 29 Mar 2025 16:06:33 -0400 Subject: [PATCH] 4.77.2 * Fixed: Channel points reward messages not appearing in chat correctly for some users. (Closes #1650) * Fixed: Clicking on a stream's up-time not being able to find the ID of the current broadcast. * Maintenance: Update the `twitch_data` module to have better typing and better use of promises. * API: Change the `createElement` method to create SVG elements with the proper namespace. --- package.json | 2 +- src/sites/twitch-twilight/modules/channel.jsx | 7 +- .../twitch-twilight/modules/chat/index.js | 85 ++ src/utilities/data/broadcast-id.gql | 11 - src/utilities/data/last-broadcast.gql | 10 - src/utilities/data/recent-broadcasts.gql | 25 + src/utilities/data/user-fetch.gql | 4 +- src/utilities/data/user-game.gql | 4 +- src/utilities/dom.ts | 15 +- src/utilities/twitch-data.ts | 825 +++++++++++------- 10 files changed, 624 insertions(+), 364 deletions(-) delete mode 100644 src/utilities/data/broadcast-id.gql delete mode 100644 src/utilities/data/last-broadcast.gql create mode 100644 src/utilities/data/recent-broadcasts.gql diff --git a/package.json b/package.json index 6041ea15..038ac1c1 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.77.1", + "version": "4.77.2", "description": "FrankerFaceZ is a Twitch enhancement suite.", "private": true, "license": "Apache-2.0", diff --git a/src/sites/twitch-twilight/modules/channel.jsx b/src/sites/twitch-twilight/modules/channel.jsx index d3151861..a5ef0571 100644 --- a/src/sites/twitch-twilight/modules/channel.jsx +++ b/src/sites/twitch-twilight/modules/channel.jsx @@ -378,11 +378,8 @@ export default class Channel extends Module { if ( event.ctrlKey || event.shiftKey || event.altKey ) return; - const history = this.router.history; - if ( history ) { - event.preventDefault(); - history.push(link); - } + event.preventDefault(); + this.router.push(link); }); return a; diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index 73a46eb9..61559a87 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -273,6 +273,12 @@ export default class ChatHook extends Module { Twilight.CHAT_ROUTES ); + this.ChatRewardEventHandler = this.fine.define( + 'chat-reward-event-handler', + n => n.unsubscribe && n.handleMessage && n.props?.messageHandlerAPI && n.props?.rewardMap, + Twilight.CHAT_ROUTES + ); + this.joined_raids = new Set; this.RaidController = this.fine.define( @@ -1614,6 +1620,85 @@ export default class ChatHook extends Module { } }); + this.ChatRewardEventHandler.ready((cls, instances) => { + const t = this, + old_subscribe = cls.prototype.subscribe; + + cls.prototype.ffzInstall = function() { + if (this._ffz_installed) + return; + + this._ffz_installed = true; + const inst = this; + const old_handle = this.handleMessage; + this.handleMessage = function(msg) { + //t.log.info('reward-message', msg, inst); + try { + if ( ! inst.props?.channelID || ! msg ) + return; + + // TODO: Refactor this code and the PubSub code to remove code dupe. + const type = msg.type, + data = msg.data?.redemption, + isAutomaticReward = type === 'automatic-reward-redeemed', + isRedeemed = 'reward-redeemed'; + + if (!data?.reward || (!isAutomaticReward && !isRedeemed)) + return; + + if ((isRedeemed && data.user_input) || (isAutomaticReward && data.reward.reward_type !== 'celebration')) + return; + + let rewardID; + if (isAutomaticReward) + rewardID = `${inst.props.channelID}:${data.reward.reward_type}`; + else + rewardID = data.reward.id; + + const reward = inst.props.rewardMap[rewardID]; + if ( ! reward ) + return; + + if ( t.chat.context.get('chat.filtering.blocked-types').has('ChannelPointsReward') ) + return; + + inst.addMessage({ + id: data.id, + type: t.chat_types.Message, + ffz_type: 'points', + ffz_reward: reward, + ffz_reward_highlight: isHighlightedReward(reward), + messageParts: [], + user: { + id: data.user.id, + login: data.user.login, + displayName: data.user.display_name + }, + timestamp: new Date(msg.data.timestamp || data.redeemed_at).getTime() + }); + + return; + + } catch(err) { + t.log.error('Error handling reward event:', err); + return old_handle.call(this, msg); + } + } + }; + + cls.prototype.subscribe = function(...args) { + try { + this.ffzInstall(); + } catch(err) { + t.log.error('Error in subscribe for RewardEventHandler:', err); + } + return old_subscribe.call(this, ...args); + } + + for(const inst of instances) + inst.subscribe(); + }); + this.ChatBufferConnector.on('mount', this.connectorMounted, this); this.ChatBufferConnector.on('update', this.connectorUpdated, this); this.ChatBufferConnector.on('unmount', this.connectorUnmounted, this); diff --git a/src/utilities/data/broadcast-id.gql b/src/utilities/data/broadcast-id.gql deleted file mode 100644 index be2ca61b..00000000 --- a/src/utilities/data/broadcast-id.gql +++ /dev/null @@ -1,11 +0,0 @@ -query FFZ_BroadcastID($id: ID, $login: String) { - user(id: $id, login: $login) { - id - stream { - id - archiveVideo { - id - } - } - } -} \ No newline at end of file diff --git a/src/utilities/data/last-broadcast.gql b/src/utilities/data/last-broadcast.gql deleted file mode 100644 index db3d2452..00000000 --- a/src/utilities/data/last-broadcast.gql +++ /dev/null @@ -1,10 +0,0 @@ -query FFZ_LastBroadcast($id: ID, $login: String) { - user(id: $id, login: $login) { - id - lastBroadcast { - id - startedAt - title - } - } -} \ No newline at end of file diff --git a/src/utilities/data/recent-broadcasts.gql b/src/utilities/data/recent-broadcasts.gql new file mode 100644 index 00000000..f71e41a5 --- /dev/null +++ b/src/utilities/data/recent-broadcasts.gql @@ -0,0 +1,25 @@ +query FFZ_RecentBroadcasts($id: ID, $login: String, $limit: Int, $cursor: Cursor, $type: BroadcastType, $sort: VideoSort, $options: VideoConnectionOptionsInput) { + user(id: $id, login: $login) { + id + videos( + first: $limit + after: $cursor + type: $type + sort: $sort + options: $options + ) { + edges { + cursor + node { + id + title + createdAt + publishedAt + } + } + pageInfo { + hasNextPage + } + } + } +} diff --git a/src/utilities/data/user-fetch.gql b/src/utilities/data/user-fetch.gql index 4194b43d..72580a33 100644 --- a/src/utilities/data/user-fetch.gql +++ b/src/utilities/data/user-fetch.gql @@ -12,7 +12,9 @@ query FFZ_FetchUser($id: ID, $login: String) { title game { id + name displayName + boxArtURL(width: 40, height: 56) } } stream { @@ -28,4 +30,4 @@ query FFZ_FetchUser($id: ID, $login: String) { isStaff } } -} \ No newline at end of file +} diff --git a/src/utilities/data/user-game.gql b/src/utilities/data/user-game.gql index 6d75ac07..97106211 100644 --- a/src/utilities/data/user-game.gql +++ b/src/utilities/data/user-game.gql @@ -5,8 +5,10 @@ query FFZ_UserGame($id: ID, $login: String) { id game { id + name displayName + boxArtURL(width: 40, height: 56) } } } -} \ No newline at end of file +} diff --git a/src/utilities/dom.ts b/src/utilities/dom.ts index 3a4e3df5..7ee7cbcc 100644 --- a/src/utilities/dom.ts +++ b/src/utilities/dom.ts @@ -33,7 +33,7 @@ const SVG_TAGS = [ 'font-face-name', 'font-face-src', 'font-face-uri', 'font-face', 'font', 'foreignObject', 'g', 'glyph', 'glyphRef', 'hkern', 'image', 'line', 'linearGradient', 'marker', 'mask', 'metadata', 'missing-glyph', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialGradient', - 'rect', 'set', 'stop', 'svg', 'switch', 'symbol', 'text', 'textPath', 'tref', + 'rect', 'set', 'stop', 'switch', 'symbol', 'text', 'textPath', 'tref', 'tspan', 'use', 'view', 'vkern' ]; @@ -199,7 +199,11 @@ export function findReactFragment( export function createElement(tag: K, props?: any, ...children: DomFragment[]): HTMLElementTagNameMap[K]; export function createElement(tag: K, props?: any, ...children: DomFragment[]): HTMLElementDeprecatedTagNameMap[K]; export function createElement(tag: string, props?: any, ...children: DomFragment[]): HTMLElement { - const el = document.createElement(tag); + const isSvg = SVG_TAGS.includes(tag); + const el = isSvg + // This is technically wrong. I do not really care. + ? document.createElementNS('http://www.w3.org/2000/svg', tag) as unknown as HTMLElement + : document.createElement(tag); if ( children.length === 0) children = null as any; @@ -207,14 +211,17 @@ export function createElement(tag: string, props?: any, ...children: DomFragment children = children[0] as any; if ( typeof props === 'string' ) - el.className = props; + el.setAttribute('class', props); else if ( props ) for(const key in props) if ( has(props, key) ) { const lk = key.toLowerCase(), prop = props[key]; - if ( lk === 'style' ) { + if ( key === 'className' ) { + el.setAttribute('class', prop); + + } else if ( lk === 'style' ) { if ( typeof prop === 'string' ) el.style.cssText = prop; else if ( prop && typeof prop === 'object' ) diff --git a/src/utilities/twitch-data.ts b/src/utilities/twitch-data.ts index 8f6b0f0a..8e45110e 100644 --- a/src/utilities/twitch-data.ts +++ b/src/utilities/twitch-data.ts @@ -10,6 +10,7 @@ import {get, debounce, TranslatableError} from 'utilities/object'; import type Apollo from './compat/apollo'; import type { DocumentNode } from 'graphql'; + declare module 'utilities/types' { interface ModuleEventMap { @@ -19,14 +20,120 @@ declare module 'utilities/types' { } } -/** - * PaginatedResult - * - * @typedef {Object} PaginatedResult - * @property {String} cursor A cursor usable to fetch the next page of results - * @property {Object[]} items This page of results - * @property {Boolean} finished Whether or not we have reached the end of results. - */ +type ID = string | number | null; +type LOGIN = string | null; + +export type QueryResult = { + data: T; + loading: boolean; + networkStatus: number; +} + +export type MutationResult = { + data: T; + extensions: { + durationMilliseconds: number; + operationName: string; + requestID: string; + } +} + +export type CategorySearch = { + totalCount: number; + pageInfo: { + hasNextPage: boolean; + } + edges: { + cursor: string; + node: TwitchCategory + }[]; +} + +export type FollowState = { + disableNotifications: boolean; + followedAt: string; +} | null; + +export type TwitchBadge = { + id: string; + image1x: string; + image2x: string; + image4x: string; + setID: string; + title: string; + version: string; + clickURL: string | null; + onClickAction: string | null; +} + +export type TwitchRecentBroadcast = { + id: string; + title: string | null; + createdAt: string; + publishedAt: string | null; +} + +export type TwitchCategory = { + id: string; + name: string; + displayName: string; + boxArtURL: string; +} + +export type TwitchBasicUser = { + id: string; + login: string; + displayName: string; + profileImageURL: string | null; + roles: { + isPartner: boolean; + } +} + +export type TwitchUser = { + id: string; + login: string; + displayName: string; + description: string | null; + profileImageURL: string | null; + profileViewCount: number; + primaryColorHex: string | null; + broadcastSettings: { + id: string; + title: string | null; + game: TwitchCategory | null; + }; + stream: { + id: string; + previewImageURL: string + } | null; + followers: { + totalCount: number + }; + roles: { + isAffiliate: boolean; + isPartner: boolean; + isStaff: boolean; + }; +} + +export type TwitchStreamCreatedAt = { + id: string; + createdAt: string; +} + +export type TwitchContentLabel = { + id: string; + localizedName: string; +} + + +export type StoredPromise = [ + Promise, + (value: T) => void, + (reason?: any) => void +] + /** * TwitchData is a container for getting different types of Twitch data @@ -35,22 +142,21 @@ declare module 'utilities/types' { */ export default class TwitchData extends Module { - apollo: Apollo = null as any; - site: GenericModule = null as any; + apollo: Apollo = null!; + site: GenericModule = null!; + private _waiting_user_ids: Map>; + private _waiting_user_logins: Map>; - private _waiting_user_ids: Map; - private _waiting_user_logins: Map; - private _waiting_stream_ids: Map; - private _waiting_stream_logins: Map; - private _waiting_flag_ids: Map; - private _waiting_flag_logins: Map; + private _waiting_stream_ids: Map>; + private _waiting_stream_logins: Map>; + + private _waiting_flag_ids: Map>; + private _waiting_flag_logins: Map>; private _loading_streams?: boolean; private _loading_flags?: boolean; - - private tag_cache: Map; - private _waiting_tags: Map; + private _loading_users?: boolean; constructor(name?: string, parent?: GenericModule) { super(name, parent); @@ -68,15 +174,14 @@ export default class TwitchData extends Module { this._waiting_flag_ids = new Map; this._waiting_flag_logins = new Map; - this.tag_cache = new Map; - this._waiting_tags = new Map; - - // The return type doesn't match, because this method returns - // a void and not a Promise. We don't care. + // Debounce our loading methods. We don't care that the + // return types don't match, so just cast to any. this._loadStreams = debounce(this._loadStreams, 50) as any; + this._loadStreamFlags = debounce(this._loadStreamFlags, 50) as any; + this._loadUsers = debounce(this._loadUsers, 50) as any; } - queryApollo( + queryApollo( query: DocumentNode | {query: DocumentNode, variables: any}, variables?: any, options?: any @@ -94,10 +199,10 @@ export default class TwitchData extends Module { thing = Object.assign(thing, options); } - return this.apollo.client.query(thing); + return this.apollo.client.query(thing) as Promise>; } - mutate( + mutate( mutation: DocumentNode | {mutation: DocumentNode, variables: any}, variables?: any, options?: any @@ -115,15 +220,15 @@ export default class TwitchData extends Module { thing = Object.assign(thing, options); } - return this.apollo.client.mutate(thing); + return this.apollo.client.mutate(thing) as Promise>; } - get languageCode() { + get languageCode(): string { const session = this.site.getSession(); return session && session.languageCode || 'en' } - get locale() { + get locale(): string { const session = this.site.getSession(); return session && session.locale || 'en-US' } @@ -133,13 +238,17 @@ export default class TwitchData extends Module { // Badges // ======================================================================== + /** + * Fetch all the global chat badges. + */ async getBadges() { - - const data = await this.queryApollo( + const data = await this.queryApollo<{ + badges: TwitchBadge[] + }>( await import(/* webpackChunkName: 'queries' */ './data/global-badges.gql') ); - return get('data.badges', data); + return data?.data?.badges; } @@ -150,18 +259,18 @@ export default class TwitchData extends Module { /** * Find categories matching the search query * - * @param {String} query The category name to match - * @param {Number} [first=15] How many results to return - * @param {String} [cursor=null] A cursor, to be used in fetching the - * next page of results. - * @returns {PaginatedResult} The results + * @param query The category name to match + * @param first How many results to return + * @param cursor A cursor, to be used in fetching the next page of results. */ async getMatchingCategories( query: string, first: number = 15, cursor: string | null = null ) { - const data = await this.queryApollo( + const data = await this.queryApollo<{ + searchCategories: CategorySearch + }>( await import(/* webpackChunkName: 'queries' */ './data/search-category.gql'), { query, @@ -192,26 +301,20 @@ export default class TwitchData extends Module { } /** - * Queries Apollo for category details given the id or name. One of (id, name) MUST be specified - * @function getCategory - * @memberof TwitchData - * @async + * Look up a category. * - * @param {int|string|null|undefined} id - the category id number (can be an integer string) - * @param {string|null|undefined} name - the category name - * @returns {Object} information about the requested stream - * - * @example - * - * console.log(this.twitch_data.getCategory(null, 'Just Chatting')); + * @param id - the category id + * @param name - the category name */ - async getCategory(id, name) { - const data = await this.queryApollo( + async getCategory(id?: ID, name?: string | null) { + const data = await this.queryApollo<{ + game: TwitchCategory | null + }>( await import(/* webpackChunkName: 'queries' */ './data/category-fetch.gql'), { id, name } ); - return get('data.game', data); + return data?.data?.game; } @@ -219,13 +322,23 @@ export default class TwitchData extends Module { // Chat // ======================================================================== + /** + * Delete a chat message. + * + * @param channel_id The channel to delete it from. + * @param message_id The message ID. + */ async deleteChatMessage( - channel_id/* :string*/, - message_id/* :string*/ + channel_id: ID, + message_id: string ) { channel_id = String(channel_id); - const data = await this.mutate({ + const data = await this.mutate<{ + deleteChatMessage: { + responseCode: string; + } + }>({ mutation: await import(/* webpackChunkName: 'queries' */ './mutations/delete-chat-message.gql'), variables: { input: { @@ -235,7 +348,7 @@ export default class TwitchData extends Module { } }); - const code = get('data.deleteChatMessage.responseCode', data); + const code = data?.data?.deleteChatMessage?.responseCode; if ( code === 'TARGET_IS_BROADCASTER' ) throw new TranslatableError( @@ -267,14 +380,23 @@ export default class TwitchData extends Module { /** * Find users matching the search query. * - * @param {String} query Text to match in the login or display name - * @param {Number} [first=15] How many results to return - * @param {String} [cursor=null] A cursor, to be used in fetching the next - * page of results. - * @returns {PaginatedResult} The results + * @param query Text to match in the login or display name + * @param first How many results to return + * @param cursor A cursor, to be used in fetching the next page of results. */ - async getMatchingUsers(query, first = 15, cursor = null) { - const data = await this.queryApollo( + async getMatchingUsers(query: string, first = 15, cursor: string | null = null) { + const data = await this.queryApollo<{ + searchUsers: { + edges: { + cursor: string; + node: TwitchBasicUser; + }[]; + totalCount: number; + pageInfo: { + hasNextPage: boolean; + } + } + }>( await import(/* webpackChunkName: 'queries' */ './data/search-user.gql'), { query, @@ -305,90 +427,104 @@ export default class TwitchData extends Module { } /** - * Queries Apollo for user details given the id or name. One of (id, login) MUST be specified - * @function getUser - * @memberof TwitchData - * @async + * Fetch information about a user. * - * @param {int|string|null|undefined} id - the user id number (can be an integer string) - * @param {string|null|undefined} login - the username - * @returns {Object} information about the requested user - * - * @example - * - * console.log(this.twitch_data.getUser(19571641, null)); + * @param id The user's ID + * @param login The user's login */ - async getUser(id, login) { - const data = await this.queryApollo( + async getUser(id?: ID, login?: LOGIN) { + const data = await this.queryApollo<{ + user: TwitchUser | null + }>( await import(/* webpackChunkName: 'queries' */ './data/user-fetch.gql'), { id, login } ); - return get('data.user', data); + return data?.data?.user; } /** - * Queries Apollo for the user's current game, details given the user id or name. One of (id, login) MUST be specified - * @function getUserGame - * @memberof TwitchData - * @async + * Fetch a user's current game. * - * @param {int|string|null|undefined} id - the user id number (can be an integer string) - * @param {string|null|undefined} login - the username - * @returns {Object} information about the requested user - * - * @example - * - * console.log(this.twitch_data.getUserGame(19571641, null)); + * @param id The user's ID + * @param login The user's login */ - async getUserGame(id, login) { - const data = await this.queryApollo( + async getUserGame(id?: ID, login?: LOGIN) { + const data = await this.queryApollo<{ + user: { + broadcastSettings: { + game: TwitchCategory | null; + } + } | null; + }>( await import(/* webpackChunkName: 'queries' */ './data/user-game.gql'), { id, login } ); - return get('data.user.broadcastSettings.game', data); + return data?.data?.user?.broadcastSettings?.game; } /** - * Queries Apollo for the logged in user's relationship to the channel with given the id or name. One of (id, login) MUST be specified - * @function getUserSelf - * @memberof TwitchData - * @async + * Look up the current user's moderator and editor status in a channel. * - * @param {int|string|null|undefined} id - the channel id number (can be an integer string) - * @param {string|null|undefined} login - the channel username - * @returns {Object} information about your status in the channel - * - * @example - * - * console.log(this.twitch_data.getUserSelf(null, "ninja")); + * @param id The target channel's ID + * @param login The target channel's login */ - async getUserSelf(id, login) { - const data = await this.queryApollo( + async getUserSelf(id?: ID, login?: LOGIN) { + const data = await this.queryApollo<{ + user: { + self: { + isEditor: boolean; + isModerator: boolean; + } + } | null; + }>( await import(/* webpackChunkName: 'queries' */ './data/user-self.gql'), { id, login } ); - return get('data.user.self', data); + return data?.data?.user?.self; } - - async getUserFollowed(id, login) { - const data = await this.queryApollo( + /** + * Look up if the current user follows a channel. + * + * @param id The target channel's ID + * @param login The target channel's login + */ + async getUserFollowed(id?: ID, login?: LOGIN) { + const data = await this.queryApollo<{ + user: { + self: { + follower: FollowState; + } + } | null; + }>( await import(/* webpackChunkName: 'queries' */ './data/user-followed.gql'), { id, login } ); - return get('data.user.self.follower', data); + return data?.data?.user?.self?.follower; } - - async followUser(channel_id, disable_notifications = false) { + /** + * Follow a channel. + * + * @param channel_id The target channel's ID + * @param disable_notifications Whether or not notifications should be disabled. + */ + async followUser(channel_id: ID, disable_notifications = false) { channel_id = String(channel_id); disable_notifications = !! disable_notifications; - const data = await this.mutate({ + const data = await this.mutate<{ + followUser: { + follow: FollowState; + error: { + code: string; + } | null; + } + }>({ mutation: await import(/* webpackChunkName: 'queries' */ './data/follow-user.gql'), variables: { input: { @@ -398,20 +534,27 @@ export default class TwitchData extends Module { } }); - console.log('result', data); - const err = get('data.followUser.error', data); + console.log('follow result', data); + const err = data?.data?.followUser?.error; if ( err?.code ) throw new Error(err.code); - return get('data.followUser.follow', data); + return data?.data?.followUser?.follow; } - - async unfollowUser(channel_id, disable_notifications = false) { + /** + * Unfollow a channel. + * + * @param channel_id The target channel's ID + */ + async unfollowUser(channel_id: ID) { channel_id = String(channel_id); - disable_notifications = !! disable_notifications; - const data = await this.mutate({ + const data = await this.mutate<{ + unfollowUser: { + follow: FollowState; + } + }>({ mutation: await import(/* webpackChunkName: 'queries' */ './data/unfollow-user.gql'), variables: { input: { @@ -420,32 +563,41 @@ export default class TwitchData extends Module { } }); - console.log('result', data); - return get('data.unfollowUser.follow', data); + console.log('unfollow result', data); + return data?.data?.unfollowUser?.follow; } /** - * Queries Apollo for the requested user's latest broadcast. One of (id, login) MUST be specified - * @function getLastBroadcast - * @memberof TwitchData - * @async + * Fetch basic information about a channel's most recent broadcast. * - * @param {int|string|null|undefined} id - the channel id number (can be an integer string) - * @param {string|null|undefined} login - the channel username - * @returns {Object} information about the requested user's latest broadcast - * - * @example - * - * console.log(this.twitch_data.getLastBroadcast(19571641, null)); + * @param id The channel's ID + * @param login The channel's login */ - async getLastBroadcast(id, login) { - const data = await this.queryApollo( - await import(/* webpackChunkName: 'queries' */ './data/last-broadcast.gql'), - { id, login } + async getLastBroadcast(id?: ID, login?: LOGIN) { + const data = await this.queryApollo<{ + user: { + videos: { + pageInfo: { + hasNextPage: boolean; + } + edges: { + cursor: string; + node: TwitchRecentBroadcast; + }[]; + } + } | null + }>( + await import(/* webpackChunkName: 'queries' */ './data/recent-broadcasts.gql'), + { + id, login, + type: 'ARCHIVE', + sort: 'TIME', + limit: 1 + } ); - return get('data.user.lastBroadcast', data); + return data?.data?.user?.videos?.edges?.[0]?.node; } @@ -453,29 +605,41 @@ export default class TwitchData extends Module { * Fetch basic information on a user from Twitch. This is automatically batched * for performance, but not directly cached. Either an id or login must be provided. * - * @param {Number|String} [id] The ID of the channel - * @param {String} [login] The username of the channel - * - * @returns {Promise} A basic user object. + * @param id The channel's ID + * @param login The channel's login */ - getUserBasic(id, login) { - return new Promise((s, f) => { - if ( id ) { - if ( this._waiting_user_ids.has(id) ) - this._waiting_user_ids.get(id).push([s,f]); - else - this._waiting_user_ids.set(id, [[s,f]]); - } else if ( login ) { - if ( this._waiting_user_logins.has(login) ) - this._waiting_user_logins.get(login).push([s,f]); - else - this._waiting_user_logins.set(login, [[s,f]]); - } else - f('id and login cannot both be null'); + getUserBasic(id?: ID, login?: LOGIN) { + let store: Map>; + let retval: Promise; + let key: string; - if ( ! this._loading_users ) - this._loadUsers(); - }) + if ( id ) { + store = this._waiting_user_ids; + key = String(id); + } else if ( login ) { + store = this._waiting_user_logins; + key = login; + } else + return Promise.reject('id and login cannot both be null'); + + let stored = store.get(key); + if (stored) + return stored[0]; + + let success: (value: TwitchBasicUser | null) => void, + failure: (reason?: any) => void; + + retval = new Promise((s, f) => { + success = s; + failure = f; + }); + + store.set(key, [retval, success!, failure!]); + + if ( ! this._loading_users ) + this._loadUsers(); + + return retval; } async _loadUsers() { @@ -489,10 +653,12 @@ export default class TwitchData extends Module { remaining = 50 - ids.length, logins = remaining > 0 ? [...this._waiting_user_logins.keys()].slice(0, remaining) : []; - let nodes; + let nodes: TwitchBasicUser[]; try { - const data = await this.queryApollo({ + const data = await this.queryApollo<{ + users: TwitchBasicUser[] + }>({ query: await import(/* webpackChunkName: 'queries' */ './data/user-bulk.gql'), variables: { ids: ids.length ? ids : null, @@ -500,23 +666,21 @@ export default class TwitchData extends Module { } }); - nodes = get('data.users', data); + nodes = data?.data?.users ?? []; } catch(err) { for(const id of ids) { - const promises = this._waiting_user_ids.get(id); + const stored = this._waiting_user_ids.get(id); this._waiting_user_ids.delete(id); - - for(const pair of promises) - pair[1](err); + if (stored) + stored[2](err); } for(const login of logins) { - const promises = this._waiting_user_logins.get(login); + const stored = this._waiting_user_logins.get(login); this._waiting_user_logins.delete(login); - - for(const pair of promises) - pair[1](err); + if (stored) + stored[2](err); } return; @@ -533,36 +697,32 @@ export default class TwitchData extends Module { id_set.delete(node.id); login_set.delete(node.login); - let promises = this._waiting_user_ids.get(node.id); - if ( promises ) { + let stored = this._waiting_user_ids.get(node.id); + if ( stored ) { this._waiting_user_ids.delete(node.id); - for(const pair of promises) - pair[0](node); + stored[1](node); } - promises = this._waiting_user_logins.get(node.login); - if ( promises ) { + stored = this._waiting_user_logins.get(node.login); + if ( stored ) { this._waiting_user_logins.delete(node.login); - for(const pair of promises) - pair[0](node); + stored[1](node); } } for(const id of id_set) { - const promises = this._waiting_user_ids.get(id); - if ( promises ) { + const stored = this._waiting_user_ids.get(id); + if ( stored ) { this._waiting_user_ids.delete(id); - for(const pair of promises) - pair[0](null); + stored[1](null); } } for(const login of login_set) { - const promises = this._waiting_user_logins.get(login); - if ( promises ) { + const stored = this._waiting_user_logins.get(login); + if ( stored ) { this._waiting_user_logins.delete(login); - for(const pair of promises) - pair[0](null); + stored[1](null); } } @@ -578,34 +738,23 @@ export default class TwitchData extends Module { // ======================================================================== /** - * Queries Apollo for the ID of the specified user's current broadcast. This ID will become the VOD ID. One of (id, login) MUST be specified - * @function getBroadcastID - * @memberof TwitchData - * @async + * Fetch the id of a channel's most recent broadcast. * - * @param {int|string|null|undefined} id - the channel id number (can be an integer string) - * @param {string|null|undefined} login - the channel username - * @returns {Object} information about the current broadcast - * - * @example - * - * console.log(this.twitch_data.getBroadcastID(null, "ninja")); + * @param id The channel's ID + * @param login The channel's login */ - async getBroadcastID(id, login) { - const data = await this.queryApollo({ - query: await import(/* webpackChunkName: 'queries' */ './data/broadcast-id.gql'), - variables: { - id, - login - } - }); - - return get('data.user.stream.archiveVideo.id', data); + async getBroadcastID(id?: ID, login?: LOGIN) { + const data = await this.getLastBroadcast(id, login); + return data?.id; } - async getChannelColor(id, login) { - const data = await this.queryApollo({ + async getChannelColor(id?: ID, login?: LOGIN) { + const data = await this.queryApollo<{ + user: { + primaryColorHex: string | null; + } | null; + }>({ query: await import(/* webpackChunkName: 'queries' */ './data/user-color.gql'), variables: { id, @@ -613,7 +762,7 @@ export default class TwitchData extends Module { } }); - return get('data.user.primaryColorHex', data); + return data?.data?.user?.primaryColorHex; } @@ -634,7 +783,7 @@ export default class TwitchData extends Module { * * console.log(this.twitch_data.getPoll(1337)); */ - async getPoll(poll_id) { + async getPoll(poll_id: ID) { const data = await this.queryApollo({ query: await import(/* webpackChunkName: 'queries' */ './data/poll-get.gql'), variables: { @@ -752,36 +901,43 @@ export default class TwitchData extends Module { // ======================================================================== /** - * Queries Apollo for stream metadata. One of (id, login) MUST be specified - * @function getStreamMeta - * @memberof TwitchData + * Fetch the stream id and creation time for a channel. * - * @param {int|string|null|undefined} id - the channel id number (can be an integer string) - * @param {string|null|undefined} login - the channel name - * @returns {Promise} information about the requested stream - * - * @example - * - * this.twitch_data.getStreamMeta(19571641, null).then(function(returnObj){console.log(returnObj);}); + * @param id The channel's ID + * @param login The channel's login */ - getStreamMeta(id, login) { - return new Promise((s, f) => { - if ( id ) { - if ( this._waiting_stream_ids.has(id) ) - this._waiting_stream_ids.get(id).push([s, f]); - else - this._waiting_stream_ids.set(id, [[s, f]]); - } else if ( login ) { - if ( this._waiting_stream_logins.has(login) ) - this._waiting_stream_logins.get(login).push([s, f]); - else - this._waiting_stream_logins.set(login, [[s, f]]); - } else - f('id and login cannot both be null'); + getStreamMeta(id?: ID, login?: LOGIN) { + let store: Map>; + let retval: Promise; + let key: string; - if ( ! this._loading_streams ) - this._loadStreams(); - }) + if ( id ) { + store = this._waiting_stream_ids; + key = String(id); + } else if ( login ) { + store = this._waiting_stream_logins; + key = login; + } else + return Promise.reject('id and login cannot both be null'); + + let stored = store.get(key); + if (stored) + return stored[0]; + + let success: (value: TwitchStreamCreatedAt | null) => void, + failure: (reason?: any) => void; + + retval = new Promise((s, f) => { + success = s; + failure = f; + }); + + store.set(key, [retval, success!, failure!]); + + if ( ! this._loading_streams ) + this._loadStreams(); + + return retval; } async _loadStreams() { @@ -795,10 +951,20 @@ export default class TwitchData extends Module { remaining = 50 - ids.length, logins = remaining > 0 ? [...this._waiting_stream_logins.keys()].slice(0, remaining) : []; - let nodes; + let nodes: { + id: string; + login: string; + stream: TwitchStreamCreatedAt | null; + }[]; try { - const data = await this.queryApollo({ + const data = await this.queryApollo<{ + users: { + id: string; + login: string; + stream: TwitchStreamCreatedAt | null; + }[] + }>({ query: await import(/* webpackChunkName: 'queries' */ './data/stream-fetch.gql'), variables: { ids: ids.length ? ids : null, @@ -806,23 +972,21 @@ export default class TwitchData extends Module { } }); - nodes = get('data.users', data); + nodes = data?.data?.users; } catch(err) { for(const id of ids) { - const promises = this._waiting_stream_ids.get(id); + const stored = this._waiting_stream_ids.get(id); this._waiting_stream_ids.delete(id); - - for(const pair of promises) - pair[1](err); + if ( stored ) + stored[2](err); } for(const login of logins) { - const promises = this._waiting_stream_logins.get(login); + const stored = this._waiting_stream_logins.get(login); this._waiting_stream_logins.delete(login); - - for(const pair of promises) - pair[1](err); + if ( stored ) + stored[2](err); } return; @@ -839,36 +1003,32 @@ export default class TwitchData extends Module { id_set.delete(node.id); login_set.delete(node.login); - let promises = this._waiting_stream_ids.get(node.id); - if ( promises ) { + let stored = this._waiting_stream_ids.get(node.id); + if ( stored ) { this._waiting_stream_ids.delete(node.id); - for(const pair of promises) - pair[0](node.stream); + stored[1](node.stream); } - promises = this._waiting_stream_logins.get(node.login); - if ( promises ) { + stored = this._waiting_stream_logins.get(node.login); + if ( stored ) { this._waiting_stream_logins.delete(node.login); - for(const pair of promises) - pair[0](node.stream); + stored[1](node.stream); } } for(const id of id_set) { - const promises = this._waiting_stream_ids.get(id); - if ( promises ) { + const stored = this._waiting_stream_ids.get(id); + if ( stored ) { this._waiting_stream_ids.delete(id); - for(const pair of promises) - pair[0](null); + stored[1](null); } } for(const login of login_set) { - const promises = this._waiting_stream_logins.get(login); - if ( promises ) { + const stored = this._waiting_stream_logins.get(login); + if ( stored ) { this._waiting_stream_logins.delete(login); - for(const pair of promises) - pair[0](null); + stored[1](null); } } @@ -889,33 +1049,38 @@ export default class TwitchData extends Module { * @param id - the channel id number (can be an integer string) * @param login - the channel name */ - getStreamFlags(id: string | number): Promise; - getStreamFlags(id: null, login: string): Promise; - getStreamFlags(id: string | number | null, login?: string | null) { - return new Promise((s, f) => { - if ( typeof id === 'number' ) - id = `${id}`; + getStreamFlags(id?: ID, login?: LOGIN) { + let store: Map>; + let retval: Promise; + let key: string; - if ( id ) { - const existing = this._waiting_flag_ids.get(id); - if ( existing ) - existing.push([s,f]); - else - this._waiting_flag_ids.set(id, [[s,f]]); + if ( id ) { + store = this._waiting_flag_ids; + key = String(id); + } else if ( login ) { + store = this._waiting_flag_logins; + key = login; + } else + return Promise.reject('id and login cannot both be null'); - } else if ( login ) { - const existing = this._waiting_flag_logins.get(login); - if ( existing ) - existing.push([s,f]); - else - this._waiting_flag_logins.set(login, [[s,f]]); + let stored = store.get(key); + if (stored) + return stored[0]; - } else - f('id and login cannot both be null'); + let success: (value: TwitchContentLabel[] | null) => void, + failure: (reason?: any) => void; - if ( ! this._loading_flags ) - this._loadStreamFlags(); - }) + retval = new Promise((s, f) => { + success = s; + failure = f; + }); + + store.set(key, [retval, success!, failure!]); + + if ( ! this._loading_flags ) + this._loadStreamFlags(); + + return retval; } async _loadStreamFlags() { @@ -929,10 +1094,18 @@ export default class TwitchData extends Module { remaining = 50 - ids.length, logins = remaining > 0 ? [...this._waiting_flag_logins.keys()].slice(0, remaining) : []; - let nodes; + let nodes: { + id: string; + login: string; + stream: { + contentClassificationLabels: TwitchContentLabel[]; + } | null; + }[]; try { - const data = await this.queryApollo({ + const data = await this.queryApollo<{ + users: typeof nodes; + }>({ query: await import(/* webpackChunkName: 'queries' */ './data/stream-flags.gql'), variables: { ids: ids.length ? ids : null, @@ -940,28 +1113,22 @@ export default class TwitchData extends Module { } }); - nodes = get('data.users', data); + nodes = data?.data?.users; } catch(err) { for(const id of ids) { - const promises = this._waiting_flag_ids.get(id); - - if ( promises ) { + const stored = this._waiting_flag_ids.get(id); + if ( stored ) { this._waiting_flag_ids.delete(id); - - for(const pair of promises) - pair[1](err); + stored[2](err); } } for(const login of logins) { - const promises = this._waiting_flag_logins.get(login); - - if ( promises ) { + const stored = this._waiting_flag_logins.get(login); + if ( stored ) { this._waiting_flag_logins.delete(login); - - for(const pair of promises) - pair[1](err); + stored[2](err); } } @@ -979,36 +1146,32 @@ export default class TwitchData extends Module { id_set.delete(node.id); login_set.delete(node.login); - let promises = this._waiting_flag_ids.get(node.id); - if ( promises ) { + let stored = this._waiting_flag_ids.get(node.id); + if ( stored ) { this._waiting_flag_ids.delete(node.id); - for(const pair of promises) - pair[0](node.stream?.contentClassificationLabels); + stored[1](node.stream?.contentClassificationLabels ?? null); } - promises = this._waiting_flag_logins.get(node.login); - if ( promises ) { + stored = this._waiting_flag_logins.get(node.login); + if ( stored ) { this._waiting_flag_logins.delete(node.login); - for(const pair of promises) - pair[0](node.stream?.contentClassificationLabels); + stored[1](node.stream?.contentClassificationLabels ?? null); } } for(const id of id_set) { - const promises = this._waiting_flag_ids.get(id); - if ( promises ) { + const stored = this._waiting_flag_ids.get(id); + if ( stored ) { this._waiting_flag_ids.delete(id); - for(const pair of promises) - pair[0](null); + stored[1](null); } } for(const login of login_set) { - const promises = this._waiting_flag_logins.get(login); - if ( promises ) { + const stored = this._waiting_flag_logins.get(login); + if ( stored ) { this._waiting_flag_logins.delete(login); - for(const pair of promises) - pair[0](null); + stored[1](null); } } @@ -1024,20 +1187,20 @@ export default class TwitchData extends Module { // ======================================================================== /** - * Search tags - * @function getMatchingTags - * @memberof TwitchData - * @async + * Fetch a list of matching tags. * - * @param {string} query - the search string - * @returns {string[]} an array containing tags that match the query string - * - * @example - * - * console.log(await this.twitch_data.getMatchingTags("Rainbo")); + * @param query The string to search for. */ async getMatchingTags(query: string) { - const data = await this.queryApollo({ + const data = await this.queryApollo<{ + searchFreeformTags: { + edges: { + node: { + tagName: string; + } + }[]; + } + }>({ query: await import(/* webpackChunkName: 'queries' */ './data/tag-search.gql'), variables: { query, @@ -1049,7 +1212,7 @@ export default class TwitchData extends Module { if ( ! Array.isArray(edges) || ! edges.length ) return []; - const out = []; + const out: string[] = []; for(const edge of edges) { const tag = edge?.node?.tagName; if ( tag )