diff --git a/res/font/ffz-fontello.eot b/res/font/ffz-fontello.eot index fe672350..d7f8d3a1 100644 Binary files a/res/font/ffz-fontello.eot and b/res/font/ffz-fontello.eot differ diff --git a/res/font/ffz-fontello.svg b/res/font/ffz-fontello.svg index 7a1d4eb1..61b5077b 100644 --- a/res/font/ffz-fontello.svg +++ b/res/font/ffz-fontello.svg @@ -114,6 +114,10 @@ + + + + @@ -128,6 +132,10 @@ + + + + diff --git a/res/font/ffz-fontello.ttf b/res/font/ffz-fontello.ttf index 73be6fc1..41678442 100644 Binary files a/res/font/ffz-fontello.ttf and b/res/font/ffz-fontello.ttf differ diff --git a/res/font/ffz-fontello.woff b/res/font/ffz-fontello.woff index c32dc3ec..c2849308 100644 Binary files a/res/font/ffz-fontello.woff and b/res/font/ffz-fontello.woff differ diff --git a/res/font/ffz-fontello.woff2 b/res/font/ffz-fontello.woff2 index e211fd39..8481f2dd 100644 Binary files a/res/font/ffz-fontello.woff2 and b/res/font/ffz-fontello.woff2 differ diff --git a/src/main.js b/src/main.js index 66a800cf..3114d80c 100644 --- a/src/main.js +++ b/src/main.js @@ -151,7 +151,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}` FrankerFaceZ.Logger = Logger; const VER = FrankerFaceZ.version_info = { - major: 4, minor: 4, revision: 1, + major: 4, minor: 4, revision: 2, commit: __git_commit__, build: __webpack_hash__, toString: () => diff --git a/src/sites/twitch-twilight/modules/directory/browse_popular.js b/src/sites/twitch-twilight/modules/directory/browse_popular.js index df7b8d73..b458de14 100644 --- a/src/sites/twitch-twilight/modules/directory/browse_popular.js +++ b/src/sites/twitch-twilight/modules/directory/browse_popular.js @@ -23,10 +23,10 @@ export default class BrowsePopular extends SiteModule { onEnable() { // Popular Directory Channel Cards - this.apollo.ensureQuery( + /*this.apollo.ensureQuery( 'BrowsePage_Popular', 'data.streams.edges.0.node.createdAt' - ); + );*/ } modifyStreams(res) { // eslint-disable-line class-methods-use-this diff --git a/src/sites/twitch-twilight/modules/directory/following.jsx b/src/sites/twitch-twilight/modules/directory/following.jsx index cf44d58c..b58100aa 100644 --- a/src/sites/twitch-twilight/modules/directory/following.jsx +++ b/src/sites/twitch-twilight/modules/directory/following.jsx @@ -169,7 +169,7 @@ export default class Following extends SiteModule { } ensureQueries () { - this.apollo.ensureQuery( + /*this.apollo.ensureQuery( 'FollowedChannels_RENAME2', 'data.currentUser.followedLiveUsers.nodes.0.stream.createdAt' ); @@ -182,7 +182,7 @@ export default class Following extends SiteModule { this.apollo.ensureQuery( 'RecommendedChannels', 'data.currentUser.recommendations.liveRecommendations.nodes.0.createdAt' - ); + );*/ if ( this.router.current_name !== 'dir-following' ) return; @@ -197,11 +197,11 @@ export default class Following extends SiteModule { get('data.currentUser.followedHosts.nodes.0.hosting.stream.createdAt', n) !== undefined ); - else if ( bit === 'live' ) + /*else if ( bit === 'live' ) this.apollo.ensureQuery( 'FollowingLive_CurrentUser', 'data.currentUser.followedLiveUsers.nodes.0.stream.createdAt' - ); + );*/ else if ( bit === 'hosts' ) this.apollo.ensureQuery( @@ -217,7 +217,7 @@ export default class Following extends SiteModule { } destroyHostMenu(event) { - if (!event || event && event.target && event.target.closest('.ffz-channel-selector-outer') === null && Date.now() > this.hostMenuBuffer) { + if (!event || ! this.hostMenu || event && event.target && event.target.closest('.ffz-channel-selector-outer') === null && Date.now() > this.hostMenuBuffer) { this.hostMenuPopper && this.hostMenuPopper.destroy(); this.hostMenu && this.hostMenu.remove(); this.hostMenuPopper = this.hostMenu = undefined; diff --git a/src/sites/twitch-twilight/modules/directory/game.jsx b/src/sites/twitch-twilight/modules/directory/game.jsx index 9a48dd24..48da8f76 100644 --- a/src/sites/twitch-twilight/modules/directory/game.jsx +++ b/src/sites/twitch-twilight/modules/directory/game.jsx @@ -29,11 +29,11 @@ export default class Game extends SiteModule { this.apollo.registerModifier('DirectoryPage_Game', GAME_QUERY); this.apollo.registerModifier('DirectoryPage_Game', res => { - setTimeout(() => + /*setTimeout(() => this.apollo.ensureQuery( 'DirectoryPage_Game', 'data.game.streams.edges.0.node.createdAt' - ), 500); + ), 500);*/ this.modifyStreams(res); }, false); @@ -62,10 +62,10 @@ export default class Game extends SiteModule { updateGameHeader(inst) { this.updateButtons(inst); - this.apollo.ensureQuery( + /*this.apollo.ensureQuery( 'DirectoryPage_Game', 'data.game.streams.edges.0.node.createdAt' - ); + );*/ } diff --git a/src/sites/twitch-twilight/modules/directory/index.jsx b/src/sites/twitch-twilight/modules/directory/index.jsx index fcf75c4b..d6c38574 100644 --- a/src/sites/twitch-twilight/modules/directory/index.jsx +++ b/src/sites/twitch-twilight/modules/directory/index.jsx @@ -38,6 +38,7 @@ export default class Directory extends SiteModule { this.inject('site.apollo'); this.inject('site.css_tweaks'); this.inject('site.web_munch'); + this.inject('site.twitch_data'); this.inject('i18n'); this.inject('settings'); @@ -200,7 +201,7 @@ export default class Directory extends SiteModule { this.DirectoryVideos.forceUpdate(); }) - this.DirectoryCard.ready(cls => { + this.DirectoryCard.ready((cls, instances) => { //const old_render = cls.prototype.render, const old_render_iconic = cls.prototype.renderIconicImage, old_render_titles = cls.prototype.renderTitles; @@ -272,13 +273,13 @@ export default class Directory extends SiteModule { // Game Directory Channel Cards // TODO: Better query handling. - this.apollo.ensureQuery( + /*this.apollo.ensureQuery( 'DirectoryPage_Game', 'data.game.streams.edges.0.node.createdAt' - ); + );*/ - //for(const inst of instances) - // this.updateCard(inst); + for(const inst of instances) + this.updateCard(inst); }); this.DirectoryCard.on('update', this.updateCard, this); @@ -373,12 +374,33 @@ export default class Directory extends SiteModule { updateUptime(inst, created_path) { const container = this.fine.getChildNode(inst), card = container && container.querySelector && container.querySelector('.preview-card-overlay'), - setting = this.settings.get('directory.uptime'), - created_at = inst.props && inst.props.createdAt || get(created_path, inst), - up_since = created_at && new Date(created_at), + setting = this.settings.get('directory.uptime'); + + if ( ! card || setting === 0 || ! inst.props || inst.props.viewCount || inst.props.animatedImageProps ) + return this.clearUptime(inst); + + let created_at = inst.props.createdAt || get(created_path, inst); + + if ( ! created_at ) { + if ( inst.ffz_stream_meta === undefined ) { + inst.ffz_stream_meta = null; + this.twitch_data.getStreamMeta(inst.props.channelId, inst.props.channelLogin).then(data => { + inst.ffz_stream_meta = data; + this.updateUptime(inst, created_path); + }); + } + + if ( inst.ffz_stream_meta ) + created_at = inst.ffz_stream_meta.createdAt; + } + + if ( ! created_at ) + return this.clearUptime(inst); + + const up_since = created_at && new Date(created_at), uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0; - if ( ! card || setting === 0 || uptime < 1 ) + if ( uptime < 1 ) return this.clearUptime(inst); const up_text = duration_to_string(uptime, false, false, false, setting === 1); diff --git a/src/std-components/autocomplete.vue b/src/std-components/autocomplete.vue index 1b759330..407b4813 100644 --- a/src/std-components/autocomplete.vue +++ b/src/std-components/autocomplete.vue @@ -305,6 +305,9 @@ export default { }, onHome(event) { + if ( event.ctrlKey || event.shiftKey || event.altKey ) + return; + if ( ! this.open ) return; @@ -316,6 +319,9 @@ export default { }, onEnd(event) { + if ( event.ctrlKey || event.shiftKey || event.altKey ) + return; + if ( ! this.open ) return; @@ -327,6 +333,9 @@ export default { }, onUp(event) { + if ( event.ctrlKey || event.shiftKey || event.altKey ) + return; + if ( ! this.open ) return; @@ -341,6 +350,9 @@ export default { }, onDown(event) { + if ( event.ctrlKey || event.shiftKey || event.altKey ) + return; + if ( ! this.open ) return; @@ -363,6 +375,9 @@ export default { }, onEnter(event) { + if ( event.ctrlKey || event.shiftKey || event.altKey ) + return; + if ( ! this.open ) return; diff --git a/src/utilities/data/stream-fetch.gql b/src/utilities/data/stream-fetch.gql new file mode 100644 index 00000000..926586fa --- /dev/null +++ b/src/utilities/data/stream-fetch.gql @@ -0,0 +1,10 @@ +query FFZ_StreamFetch($ids: [ID!], $logins: [String!]) { + users(ids: $ids, logins: $logins) { + id + login + stream { + id + createdAt + } + } +} \ No newline at end of file diff --git a/src/utilities/data/stream-single.gql b/src/utilities/data/stream-single.gql new file mode 100644 index 00000000..b65dfc97 --- /dev/null +++ b/src/utilities/data/stream-single.gql @@ -0,0 +1,9 @@ +query FFZ_SingleStream($id: ID, $login: String) { + user(id: $id, login: $login) { + id + stream { + id + createdAt + } + } +} \ No newline at end of file diff --git a/src/utilities/object.js b/src/utilities/object.js index bdad79f1..9b908548 100644 --- a/src/utilities/object.js +++ b/src/utilities/object.js @@ -179,6 +179,8 @@ export function deep_equals(object, other, ignore_undefined = false, seen, other return false; if ( typeof object !== 'object' ) return false; + if ( (object === null) !== (other === null) ) + return false; if ( ! seen ) seen = new Set; diff --git a/src/utilities/twitch-data.js b/src/utilities/twitch-data.js index c1023ff0..212f5976 100644 --- a/src/utilities/twitch-data.js +++ b/src/utilities/twitch-data.js @@ -18,10 +18,14 @@ export default class TwitchData extends Module { this.inject('site.apollo'); this.inject('site.web_munch'); + this._waiting_stream_ids = new Map; + this._waiting_stream_logins = new Map; + this.tag_cache = new Map; this._waiting_tags = new Map; - this._loadTags = debounce(this._loadTags.bind(this), 50); + this._loadTags = debounce(this._loadTags, 50); + this._loadStreams = debounce(this._loadStreams, 50); } queryApollo(query, variables, options) { @@ -56,7 +60,7 @@ export default class TwitchData extends Module { return this._search; const apollo = this.apollo.client, - core = this.listeners.getCore(), + core = this.site.getCore(), search_module = this.web_munch.getModule('algolia-search'), SearchClient = search_module && search_module.a; @@ -121,55 +125,220 @@ export default class TwitchData extends Module { } + // ======================================================================== + // Stream Up-Type (Uptime and Type, for Directory Purposes) + // ======================================================================== + + getStreamMeta(id, login) { + return new Promise(async (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'); + + if ( ! this._loading_streams ) + this._loadStreams(); + }) + } + + async _loadStreams() { + if ( this._loading_streams ) + return; + + this._loading_streams = true; + + // Get the first 50... things. + const ids = [...this._waiting_stream_ids.keys()].slice(0, 50), + remaining = 50 - ids.length, + logins = remaining > 0 ? [...this._waiting_stream_logins.keys()].slice(0, remaining) : []; + + let nodes; + + try { + const data = await this.queryApollo({ + query: require('./data/stream-fetch.gql'), + variables: { + ids: ids.length ? ids : null, + logins: logins.length ? logins : null + } + }); + + nodes = get('data.users', data); + + } catch(err) { + for(const id of ids) { + const promises = this._waiting_stream_ids.get(id); + this._waiting_stream_ids.delete(id); + + for(const pair of promises) + pair[1](err); + } + + for(const login of logins) { + const promises = this._waiting_stream_logins.get(login); + this._waiting_stream_logins.delete(login); + + for(const pair of promises) + pair[1](err); + } + + return; + } + + const id_set = new Set(ids), + login_set = new Set(logins); + + if ( Array.isArray(nodes) ) + for(const node of nodes) { + if ( ! node || ! node.id ) + continue; + + id_set.delete(node.id); + login_set.delete(node.login); + + let promises = this._waiting_stream_ids.get(node.id); + if ( promises ) { + this._waiting_stream_ids.delete(node.id); + for(const pair of promises) + pair[0](node.stream); + } + + promises = this._waiting_stream_logins.get(node.login); + if ( promises ) { + this._waiting_stream_logins.delete(node.login); + for(const pair of promises) + pair[0](node.stream); + } + } + + for(const id of id_set) { + const promises = this._waiting_stream_ids.get(id); + if ( promises ) { + this._waiting_stream_ids.delete(id); + for(const pair of promises) + pair[0](null); + } + } + + for(const login of login_set) { + const promises = this._waiting_stream_logins.get(login); + if ( promises ) { + this._waiting_stream_logins.delete(login); + for(const pair of promises) + pair[0](null); + } + } + + this._loading_streams = false; + + if ( this._waiting_stream_ids.size || this._waiting_stream_logins.size ) + this._loadStreams(); + } + + // ======================================================================== // Tags // ======================================================================== + memorizeTag(node, dispatch = true) { + // We want properly formed tags. + if ( ! node || ! node.id || ! node.tagName || ! node.localizedName ) + return; + + let old = null; + if ( this.tag_cache.has(node.id) ) + old = this.tag_cache.get(old); + + const match = node.isLanguageTag && LANGUAGE_MATCHER.exec(node.tagName), + lang = match && match[1] || null; + + const new_tag = { + id: node.id, + value: node.id, + is_language: node.isLanguageTag, + language: lang, + name: node.tagName, + label: node.localizedName + }; + + if ( node.localizedDescription ) + new_tag.description = node.localizedDescription; + + const tag = old ? Object.assign(old, new_tag) : new_tag; + this.tag_cache.set(tag.id, tag); + + if ( dispatch && tag.description && this._waiting_tags.has(tag.id) ) { + const promises = this._waiting_tags.get(tag.id); + this._waiting_tags.delete(tag.id); + for(const pair of promises) + pair[0](tag); + } + + return tag; + } + async _loadTags() { if ( this._loading_tags ) return; this._loading_tags = true; - const processing = this._waiting_tags; - this._waiting_tags = new Map; + + // Get the first 50 tags. + const ids = [...this._waiting_tags.keys()].slice(0, 50); + + let nodes try { const data = await this.queryApollo( require('./data/tags-fetch.gql'), { - ids: [...processing.keys()] + ids } ); - const nodes = get('data.contentTags', data); - if ( Array.isArray(nodes) ) - for(const node of nodes) { - const tag = { - id: node.id, - value: node.id, - is_language: node.isLanguageTag, - name: node.tagName, - label: node.localizedName, - description: node.localizedDescription - }; - - this.tag_cache.set(tag.id, tag); - const promises = processing.get(tag.id); - if ( promises ) - for(const pair of promises) - pair[0](tag); - - promises.delete(tag.id); - } - - for(const promises of processing.values()) - for(const pair of promises) - pair[0](null); + nodes = get('data.contentTags', data); } catch(err) { - for(const promises of processing.values()) + for(const id of ids) { + const promises = this._waiting_tags.get(id); + this._waiting_tags.delete(id); + for(const pair of promises) pair[1](err); + } + + return; + } + + const id_set = new Set(ids); + + if ( Array.isArray(nodes) ) + for(const node of nodes) { + const tag = this.memorizeTag(node, false), + promises = this._waiting_tags.get(tag.id); + + this._waiting_tags.delete(tag.id); + id_set.delete(tag.id); + + if ( promises ) + for(const pair of promises) + pair[0](tag); + } + + for(const id of id_set) { + const promises = this._waiting_tags.get(id); + this._waiting_tags.delete(id); + + for(const pair of promises) + pair[0](null); } this._loading_tags = false; @@ -179,6 +348,10 @@ export default class TwitchData extends Module { } getTag(id, want_description = false) { + // Make sure we weren't accidentally handed a tag object. + if ( id && id.id ) + id = id.id; + if ( this.tag_cache.has(id) ) { const out = this.tag_cache.get(id); if ( out && (out.description || ! want_description) ) @@ -197,6 +370,10 @@ export default class TwitchData extends Module { } getTagImmediate(id, callback, want_description = false) { + // Make sure we weren't accidentally handed a tag object. + if ( id && id.id ) + id = id.id; + let out = null; if ( this.tag_cache.has(id) ) out = this.tag_cache.get(id); @@ -223,17 +400,7 @@ export default class TwitchData extends Module { continue; seen.add(node.id); - const tag = { - id: node.id, - value: node.id, - is_language: node.isLanguageTag, - name: node.tagName, - label: node.localizedName, - description: node.localizedDescription - }; - - this.tag_cache.set(tag.id, tag); - out.push(tag); + out.push(this.memorizeTag(node)); } return out; @@ -258,46 +425,89 @@ export default class TwitchData extends Module { return out; } - async getMatchingTags(query, locale) { + async getMatchingTags(query, locale, category = null) { if ( ! locale ) locale = this.locale; - const data = await this.searchClient.queryForType( - 'tag', query, generateUUID(), { - hitsPerPage: 100, - facetFilters: [ + locale = locale.toLowerCase(); - ], - restrictSearchableAttributes: [ - `localizations.${locale}`, - 'tag_name' - ] - } - ); + let nodes; + + if ( category ) { + const data = await this.searchClient.queryForType( + 'stream_tag', query, generateUUID(), { + hitsPerPage: 100, + faceFilters: [ + `category_id:${category}` + ], + restrictSearchableAttributes: [ + `localizations.${locale}`, + 'tag_name' + ] + } + ); + + nodes = get('streamTags.hits', data); + + } else { + const data = await this.searchClient.queryForType( + 'tag', query, generateUUID(), { + hitsPerPage: 100, + facetFilters: [ + ['tag_scope:SCOPE_ALL', 'tag_scope:SCOPE_CATEGORY'] + ], + restrictSearchableAttributes: [ + `localizations.${locale}`, + 'tag_name' + ] + } + ); + + nodes = get('tags.hits', data); + } - const nodes = get('streamTags.hits', data); if ( ! Array.isArray(nodes) ) return []; const out = [], seen = new Set; for(const node of nodes) { - if ( ! node || seen.has(node.tag_id) ) + const tag_id = node.tag_id || node.objectID; + if ( ! node || seen.has(tag_id) ) continue; - seen.add(node.tag_id); - if ( ! this.tag_cache.has(node.tag_id) ) { + seen.add(tag_id); + if ( ! this.tag_cache.has(tag_id) ) { + const match = node.tag_name && LANGUAGE_MATCHER.exec(node.tag_name), + lang = match && match[1] || null; + const tag = { - id: node.tag_id, - value: node.tag_id, - is_language: node.tag_name && LANGUAGE_MATCHER.test(node.tag_name), + id: tag_id, + value: tag_id, + is_language: lang != null, + language: lang, label: node.localizations && (node.localizations[locale] || node.localizations['en-us']) || node.tag_name }; - this.tag_cache.set(tag.id); + if ( node.description_localizations ) { + const desc = node.description_localizations[locale] || node.description_localizations['en-us']; + if ( desc ) + tag.description = desc; + } + + this.tag_cache.set(tag.id, tag); out.push(tag); } else { - out.push(this.tag_cache.get(node.tag_id)); + const tag = this.tag_cache.get(tag_id); + if ( ! tag.description && node.description_localizations ) { + const desc = node.description_localizations[locale] || node.description_localizations['en-us']; + if ( desc ) { + tag.description = desc; + this.tag_cache.set(tag.id, tag); + } + } + + out.push(tag); } } diff --git a/styles/icons.scss b/styles/icons.scss index dce59a1e..cce8b72d 100644 --- a/styles/icons.scss +++ b/styles/icons.scss @@ -128,6 +128,8 @@ .ffz-i-link-ext:before { content: '\f08e'; } /* '' */ .ffz-i-twitter:before { content: '\f099'; } /* '' */ .ffz-i-github:before { content: '\f09b'; } /* '' */ +.ffz-i-sort-down:before { content: '\f0dd'; } /* '' */ +.ffz-i-sort-up:before { content: '\f0de'; } /* '' */ .ffz-i-gauge:before { content: '\f0e4'; } /* '' */ .ffz-i-download-cloud:before { content: '\f0ed'; } /* '' */ .ffz-i-upload-cloud:before { content: '\f0ee'; } /* '' */ @@ -135,6 +137,8 @@ .ffz-i-keyboard:before { content: '\f11c'; } /* '' */ .ffz-i-calendar-empty:before { content: '\f133'; } /* '' */ .ffz-i-ellipsis-vert:before { content: '\f142'; } /* '' */ +.ffz-i-sort-alt-up:before { content: '\f160'; } /* '' */ +.ffz-i-sort-alt-down:before { content: '\f161'; } /* '' */ .ffz-i-language:before { content: '\f1ab'; } /* '' */ .ffz-i-twitch:before { content: '\f1e8'; } /* '' */ .ffz-i-bell-off:before { content: '\f1f7'; } /* '' */ @@ -146,7 +150,6 @@ .ffz-i-window-restore:before { content: '\f2d2'; } /* '' */ .ffz-i-window-close:before { content: '\f2d3'; } /* '' */ - .ffz-i-pd-1:before { margin-right: 1rem } .ffz-i-pd-2:before { margin-right: 2rem } .ffz-i-pd-3:before { margin-right: 3rem } \ No newline at end of file