From 1ee737f2ca75b5a9687d2e577c959ebce98ddb70 Mon Sep 17 00:00:00 2001 From: SirStendec Date: Wed, 9 Oct 2024 17:09:09 -0400 Subject: [PATCH] 4.75.0 * Added: Setting to use native styling for subscription notices in chat. (Closes #1551) * Added: Setting to hide the stories UI in the side navigation. (Closes #1531) * Added: Setting to hide specific users from the directory and sidebar. * Changed: Hide the top navigation's search field when using minimal navigation for a cleaner look. (Closes #1556) * Changed: More internal changes for the support to Manifest v3. * Fixed: Uploading logs from the FFZ Control Center not working. * API Changed: Removed support for `no_sanitize` from `setChildren`. I don't think anything was currently using this, but going forward it is unsupported. We want to avoid using `innerHTML` as much as possible to simplify the approval process. --- package.json | 5 +- pnpm-lock.yaml | 24 + src/entry_ext.js | 48 +- src/esbridge.js | 2 +- .../main_menu/components/async-text.vue | 4 +- src/modules/main_menu/index.js | 6 +- src/settings/index.ts | 5 +- src/settings/providers.ts | 631 +++++++++++------- .../twitch-twilight/modules/chat/index.js | 23 +- .../modules/css_tweaks/index.js | 12 + .../css_tweaks/styles/minimal-navigation.scss | 11 +- .../modules/directory/index.jsx | 64 ++ src/sites/twitch-twilight/modules/layout.js | 33 +- src/utilities/dom.ts | 18 +- src/utilities/types.ts | 7 + src/worker.ts | 31 + webpack.config.js | 25 +- 17 files changed, 669 insertions(+), 280 deletions(-) create mode 100644 src/worker.ts diff --git a/package.json b/package.json index f0a449bd..fc3c25e0 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.74.1", + "version": "4.75.0", "description": "FrankerFaceZ is a Twitch enhancement suite.", "private": true, "license": "Apache-2.0", @@ -11,7 +11,9 @@ "clean": "rimraf dist", "dev": "cross-env NODE_ENV=development webpack serve", "dev:prod": "cross-env NODE_ENV=production webpack serve", + "dev:ext": "cross-env NODE_ENV=development FFZ_EXTENSION=true webpack serve", "build": "pnpm build:prod", + "build:ext": "cross-env NODE_ENV=production FFZ_EXTENSION=true webpack build", "build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json", "build:prod": "cross-env NODE_ENV=production webpack build", "build:dev": "cross-env NODE_ENV=development webpack build", @@ -25,6 +27,7 @@ }, "devDependencies": { "@ffz/fontello-cli": "^1.0.4", + "@types/chrome": "^0.0.277", "@types/crypto-js": "^4.2.1", "@types/js-cookie": "^3.0.6", "@types/safe-regex": "^1.1.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef82d740..37aa2a3e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ devDependencies: '@ffz/fontello-cli': specifier: ^1.0.4 version: 1.0.4 + '@types/chrome': + specifier: ^0.0.277 + version: 0.0.277 '@types/crypto-js': specifier: ^4.2.1 version: 4.2.1 @@ -588,6 +591,13 @@ packages: '@types/node': 20.5.7 dev: true + /@types/chrome@0.0.277: + resolution: {integrity: sha512-qoTgBcDWblSsX+jvFnpUlLUE3LAuOhZfBh9MyMWMQHDsQiYVgBvdZWu9COrdB9+aNnInEyXcFgfc2HE16sdSYQ==} + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + dev: true + /@types/connect-history-api-fallback@1.5.0: resolution: {integrity: sha512-4x5FkPpLipqwthjPsF7ZRbOv3uoLUFkTA9G9v583qi4pACvq0uTELrB8OLUzPWUI4IJIyvM85vzkV1nyiI2Lig==} dependencies: @@ -641,6 +651,20 @@ packages: '@types/serve-static': 1.15.2 dev: true + /@types/filesystem@0.0.36: + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + dependencies: + '@types/filewriter': 0.0.33 + dev: true + + /@types/filewriter@0.0.33: + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + dev: true + + /@types/har-format@1.2.16: + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + dev: true + /@types/http-errors@2.0.1: resolution: {integrity: sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==} dev: true diff --git a/src/entry_ext.js b/src/entry_ext.js index 267dfc14..fa8d435b 100644 --- a/src/entry_ext.js +++ b/src/entry_ext.js @@ -1,16 +1,52 @@ /* eslint strict: off */ 'use strict'; (() => { - // Don't run on certain sub-domains. - if ( /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev|gql|passport)\./.test(location.hostname) ) + const browser = globalThis.browser ?? globalThis.chrome; + + if ( + // Don't run on certain sub-domains. + /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev|gql|passport)\./.test(location.hostname) + || + // Don't run on pages that have disabled FFZ. + /disable_frankerfacez/.test(location.search) + ) { + // Tell the service worker we aren't injecting. + browser.runtime.sendMessage({ + type: 'ffz_not_supported' + }); return; + } - if ( /disable_frankerfacez/.test(location.search) ) - return; + // Make sure to wake the service worker up early. + browser.runtime.sendMessage({ + type: 'ffz_injecting' + }); - const browser = globalThis.browser ?? globalThis.chrome, + // Set up the extension message bridge. + window.addEventListener('message', evt => { + if (evt.source !== window) + return; - HOST = location.hostname, + if (evt.data && evt.data.type === 'ffz_to_ext') + browser.runtime.sendMessage(evt.data.data, resp => { + if (resp) + window.postMessage({ + type: 'ffz_from_ext', + data: resp + }, '*'); + }); + }); + + browser.runtime.onMessage.addListener((msg, sender) => { + window.postMessage({ + type: 'ffz_from_ext', + data: msg + }, '*'); + return true; + }); + + // Now, inject our script into the page context. + const HOST = location.hostname, SERVER = browser.runtime.getURL("web"), script = document.createElement('script'); diff --git a/src/esbridge.js b/src/esbridge.js index ec26b1b3..922f2a67 100644 --- a/src/esbridge.js +++ b/src/esbridge.js @@ -34,7 +34,7 @@ class FFZESBridge { document.addEventListener('readystatechange', event => { if ( document.documentElement ) - document.documentElement.dataset.ffzEsbridge = true; + document.documentElement.dataset.ffzEsbridge = true; }); } diff --git a/src/modules/main_menu/components/async-text.vue b/src/modules/main_menu/components/async-text.vue index 071651c0..ab33aafc 100644 --- a/src/modules/main_menu/components/async-text.vue +++ b/src/modules/main_menu/components/async-text.vue @@ -93,7 +93,7 @@ export default { return; this.uploading = true; - const response = await fetch('https://putco.de', { + const response = await fetch('https://logs.frankerfacez.com', { method: 'PUT', body: this.text }); @@ -120,4 +120,4 @@ export default { } } - \ No newline at end of file + diff --git a/src/modules/main_menu/index.js b/src/modules/main_menu/index.js index ee518646..42c64ff8 100644 --- a/src/modules/main_menu/index.js +++ b/src/modules/main_menu/index.js @@ -255,11 +255,11 @@ export default class MainMenu extends Module { // If we're on a page with minimal root, we want to open settings // in a popout as we're almost certainly within Popout Chat. const layout = this.resolve('site.layout'), - item = evt.item, - event = evt.event; + item = evt?.item, + event = evt?.event; if ( (layout && layout.is_minimal) || (event && (event.ctrlKey || event.shiftKey)) ) { - if ( ! this.openPopout(item) ) + if ( ! this.openPopout(item) && evt ) evt.errored = true; return; } diff --git a/src/settings/index.ts b/src/settings/index.ts index 2a9efcf9..3d7fd1d5 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -245,7 +245,10 @@ export default class SettingsManager extends Module<'settings', SettingsEvents> window.addEventListener('message', event => { const type = event.data?.ffz_type; - if ( type === 'request-context' && event.source ) { + if ( type === 'open-settings' ) + this.emit('main_menu:open'); + + else if ( type === 'request-context' && event.source ) { this._context_proxies.add(event.source); this._updateContextProxies(event.source); } diff --git a/src/settings/providers.ts b/src/settings/providers.ts index e9fb3ae8..b036bc03 100644 --- a/src/settings/providers.ts +++ b/src/settings/providers.ts @@ -11,6 +11,7 @@ import {EventEmitter} from 'utilities/events'; import {TicketLock, has, once} from 'utilities/object'; import type SettingsManager from '.'; import type { OptionalArray, OptionalPromise, ProviderTypeMap } from '../utilities/types'; +import { EXTENSION } from '../utilities/constants'; const DB_VERSION = 1, NOT_WWW_TWITCH = window.location.host !== 'www.twitch.tv', @@ -144,6 +145,255 @@ export abstract class AdvancedSettingsProvider extends SettingsProvider { } +export abstract class RemoteSettingsProvider extends AdvancedSettingsProvider { + + // State and Storage + private _start_time: number; + private _cached: Map; + + private _blobs: boolean | null; + private _rpc: Map void, () => void]>; + private _last_id: number; + + private resolved_ready: boolean; + private _ready_wait_resolve?: (() => void) | null; + private _ready_wait_fail?: ((err: any) => void) | null; + private _ready_wait?: Promise | null; + + constructor(manager: SettingsManager) { + super(manager); + + this._start_time = performance.now(); + + this._rpc = new Map; + + this._cached = new Map; + this.resolved_ready = false; + this.ready = false; + this._ready_wait = null; + + this._blobs = null; + this._last_id = 0; + } + + get supportsBlobs() { + return this._blobs ?? false; + } + + // Stuff + + broadcastTransfer() { + // TODO: Figure out what this would mean for CORS. + } + + disableEvents() { + // TODO: Figure out what this would mean for CORS. + } + + + // Initialization + + protected resolveReady(success: boolean, data?: any) { + if ( this.manager ) + this.manager.log.info(`${this.constructor.name} ready in ${(performance.now() - this._start_time).toFixed(5)}ms`); + + this.resolved_ready = true; + this.ready = success; + + if ( success && this._ready_wait_resolve ) + this._ready_wait_resolve(); + else if ( ! success && this._ready_wait_fail ) + this._ready_wait_fail(data); + } + + awaitReady() { + if ( this.resolved_ready ) { + if ( this.ready ) + return Promise.resolve(); + return Promise.reject(); + } + + if ( this._ready_wait ) + return this._ready_wait; + + return this._ready_wait = new Promise((resolve, fail) => { + this._ready_wait_resolve = resolve; + this._ready_wait_fail = fail; + + }).finally(() => { + this._ready_wait = null; + this._ready_wait_resolve = null; + this._ready_wait_fail = null; + }); + } + + + // Provider Methods + + get(key: string, default_value?: T): T { + return this._cached.has(key) + ? this._cached.get(key) + : default_value; + } + + set(key: string, value: any) { + if ( value === undefined ) { + if ( this.has(key) ) + this.delete(key); + return; + } + + this._cached.set(key, value); + this.rpc({ffz_type: 'set', key, value}) + .catch(err => this.manager.log.error('Error setting value', err)); + this.emit('set', key, value, false); + } + + delete(key: string) { + this._cached.delete(key); + this.rpc({ffz_type: 'delete', key}) + .catch(err => this.manager.log.error('Error deleting value', err)); + this.emit('set', key, undefined, true); + } + + clear() { + const old_cache = this._cached; + this._cached = new Map; + for(const key of old_cache.keys()) + this.emit('changed', key, undefined, true); + + this.rpc('clear') + .catch(err => this.manager.log.error('Error clearing storage', err)); + } + + has(key: string) { return this._cached.has(key); } + keys() { return this._cached.keys(); } + entries() { return this._cached.entries(); } + get size() { return this._cached.size; } + + async flush() { + await this.rpc('flush'); + } + + + // Provider Methods: Blobs + + async getBlob(key: string) { + const msg = await this.rpc({ffz_type: 'get-blob', key}); + return msg ? deserializeBlob(msg) : null; + } + + async setBlob(key: string, value: BlobLike) { + await this.rpc({ + ffz_type: 'set-blob', + key, + value: await serializeBlob(value) + }); + } + + async deleteBlob(key: string) { + await this.rpc({ + ffz_type: 'delete-blob', + key + }); + } + + async hasBlob(key: string) { + return this.rpc({ffz_type: 'has-blob', key}); + } + + async clearBlobs() { + await this.rpc('clear-blobs'); + } + + async blobKeys() { + return this.rpc('blob-keys'); + } + + + // Communication + + abstract send(msg: string | CorsMessage, transfer?: OptionalArray): void; + + rpc( + msg: K | RPCInputMessage, + transfer?: OptionalArray + ) { + const id = ++this._last_id; + + return new Promise>((resolve,fail) => { + this._rpc.set(id, [resolve, fail]); + let out: CorsMessage; + if ( typeof msg === 'string' ) + out = {ffz_type: msg} as CorsMessage; + else + out = msg as unknown as CorsMessage; + + out.id = id; + this.send(out, transfer); + }); + } + + handleMessage(msg: CorsMessage) { + if ( msg.ffz_type === 'ready' ) + this.rpc('init-load').then(msg => { + this._blobs = msg.blobs; + for(const [key, value] of Object.entries(msg.values)) + this._cached.set(key, value); + + this.resolveReady(true); + + }).catch(err => { + this.resolveReady(false, err); + }); + + else if ( msg.ffz_type === 'change' ) + this.onChange(msg); + + else if ( msg.ffz_type === 'change-blob' ) + this.emit('changed-blob', msg.key, msg.deleted); + + else if ( msg.ffz_type === 'clear-blobs' ) + this.emit('clear-blobs'); + + else if ( msg.ffz_type === 'reply' || msg.ffz_type === 'reply-error' ) + this.onReply(msg); + + else + this.manager.log.warn('Unknown Message', msg.ffz_type, msg); + } + + onChange(msg: RPCInputMessage<'change'>) { + const key = msg.key, + value = msg.value, + deleted = msg.deleted; + + if ( deleted ) { + this._cached.delete(key); + this.emit('changed', key, undefined, true); + } else { + this._cached.set(key, value); + this.emit('changed', key, value, false); + } + } + + onReply(msg: CorsReplyMessage | CorsReplyErrorMessage) { + const id = msg.id, + success = msg.ffz_type === 'reply', + cbs = this._rpc.get(id); + if ( ! cbs ) + return this.manager.log.warn('Received reply for unknown ID', id); + + this._rpc.delete(id); + if ( success ) + cbs[0](msg.reply); + else + cbs[1](); + } +} + + + // ============================================================================ @@ -1177,7 +1427,7 @@ export class IndexedDBProvider extends AdvancedSettingsProvider { // CrossOriginStorageBridge // ============================================================================ -export class CrossOriginStorageBridge extends AdvancedSettingsProvider { +export class CrossOriginStorageBridge extends RemoteSettingsProvider { // Static Stuff @@ -1194,36 +1444,11 @@ export class CrossOriginStorageBridge extends AdvancedSettingsProvider { static shouldUpdate = false; // State and Storage - private _start_time: number; - private _cached: Map; - - private _blobs: boolean | null; - private _rpc: Map void, () => void]>; - private _last_id: number; - private frame: HTMLIFrameElement | null; - private _boundHandleMessage?: ((event: MessageEvent) => void) | null; - - private resolved_ready: boolean; - private _ready_wait_resolve?: (() => void) | null; - private _ready_wait_fail?: ((err: any) => void) | null; - private _ready_wait?: Promise | null; constructor(manager: SettingsManager) { super(manager); - this._start_time = performance.now(); - - this._rpc = new Map; - - this._cached = new Map; - this.resolved_ready = false; - this.ready = false; - this._ready_wait = null; - - this._blobs = null; - this._last_id = 0; - const frame = this.frame = document.createElement('iframe'); frame.src = (this.manager.root as any).host === 'youtube' ? '//www.youtube.com/__ffz_bridge/' : @@ -1232,13 +1457,10 @@ export class CrossOriginStorageBridge extends AdvancedSettingsProvider { frame.style.width = '0'; frame.style.height = '0'; - this._boundHandleMessage = this.onMessage.bind(this); - window.addEventListener('message', this._boundHandleMessage); - document.body.appendChild(frame); - } + this.onMessage = this.onMessage.bind(this); - get supportsBlobs() { - return this._blobs ?? false; + window.addEventListener('message', this.onMessage); + document.body.appendChild(frame); } // Stuff @@ -1252,128 +1474,16 @@ export class CrossOriginStorageBridge extends AdvancedSettingsProvider { } - // Initialization - - private _resolveReady(success: boolean, data?: any) { - if ( this.manager ) - this.manager.log.info(`COSB ready in ${(performance.now() - this._start_time).toFixed(5)}ms`); - - this.resolved_ready = true; - this.ready = success; - - if ( success && this._ready_wait_resolve ) - this._ready_wait_resolve(); - else if ( ! success && this._ready_wait_fail ) - this._ready_wait_fail(data); - } - - awaitReady() { - if ( this.resolved_ready ) { - if ( this.ready ) - return Promise.resolve(); - return Promise.reject(); - } - - if ( this._ready_wait ) - return this._ready_wait; - - return this._ready_wait = new Promise((resolve, fail) => { - this._ready_wait_resolve = resolve; - this._ready_wait_fail = fail; - - }).finally(() => { - this._ready_wait = null; - this._ready_wait_resolve = null; - this._ready_wait_fail = null; - }); - } - - - // Provider Methods - - get(key: string, default_value?: T): T { - return this._cached.has(key) - ? this._cached.get(key) - : default_value; - } - - set(key: string, value: any) { - if ( value === undefined ) { - if ( this.has(key) ) - this.delete(key); - return; - } - - this._cached.set(key, value); - this.rpc({ffz_type: 'set', key, value}) - .catch(err => this.manager.log.error('Error setting value', err)); - this.emit('set', key, value, false); - } - - delete(key: string) { - this._cached.delete(key); - this.rpc({ffz_type: 'delete', key}) - .catch(err => this.manager.log.error('Error deleting value', err)); - this.emit('set', key, undefined, true); - } - - clear() { - const old_cache = this._cached; - this._cached = new Map; - for(const key of old_cache.keys()) - this.emit('changed', key, undefined, true); - - this.rpc('clear') - .catch(err => this.manager.log.error('Error clearing storage', err)); - } - - has(key: string) { return this._cached.has(key); } - keys() { return this._cached.keys(); } - entries() { return this._cached.entries(); } - get size() { return this._cached.size; } - - async flush() { - await this.rpc('flush'); - } - - - // Provider Methods: Blobs - - async getBlob(key: string) { - const msg = await this.rpc({ffz_type: 'get-blob', key}); - return msg ? deserializeBlob(msg) : null; - } - - async setBlob(key: string, value: BlobLike) { - await this.rpc({ - ffz_type: 'set-blob', - key, - value: await serializeBlob(value) - }); - } - - async deleteBlob(key: string) { - await this.rpc({ - ffz_type: 'delete-blob', - key - }); - } - - async hasBlob(key: string) { - return this.rpc({ffz_type: 'has-blob', key}); - } - - async clearBlobs() { - await this.rpc('clear-blobs'); - } - - async blobKeys() { - return this.rpc('blob-keys'); - } - - // CORS Communication + onMessage(event: MessageEvent) { + const msg = event.data; + if ( ! msg || ! msg.ffz_type ) + return; + + this.handleMessage(msg); + } + send(msg: string | CorsMessage, transfer?: OptionalArray) { if ( typeof msg === 'string' ) msg = {ffz_type: msg} as any; @@ -1390,90 +1500,138 @@ export class CrossOriginStorageBridge extends AdvancedSettingsProvider { } } - rpc( - msg: K | RPCInputMessage, - transfer?: OptionalArray - ) { - const id = ++this._last_id; - - return new Promise>((resolve,fail) => { - this._rpc.set(id, [resolve, fail]); - let out: CorsMessage; - if ( typeof msg === 'string' ) - out = {ffz_type: msg} as CorsMessage; - else - out = msg as unknown as CorsMessage; - - out.id = id; - this.send(out, transfer); - }); - } - - onMessage(event: MessageEvent) { - const msg = event.data; - if ( ! msg || ! msg.ffz_type ) - return; - - if ( msg.ffz_type === 'ready' ) - this.rpc('init-load').then(msg => { - this._blobs = msg.blobs; - for(const [key, value] of Object.entries(msg.values)) - this._cached.set(key, value); - - this._resolveReady(true); - - }).catch(err => { - this._resolveReady(false, err); - }); - - else if ( msg.ffz_type === 'change' ) - this.onChange(msg); - - else if ( msg.ffz_type === 'change-blob' ) - this.emit('changed-blob', msg.key, msg.deleted); - - else if ( msg.ffz_type === 'clear-blobs' ) - this.emit('clear-blobs'); - - else if ( msg.ffz_type === 'reply' || msg.ffz_type === 'reply-error' ) - this.onReply(msg); - - else - this.manager.log.warn('Unknown Message', msg.ffz_type, msg); - } - - onChange(msg: RPCInputMessage<'change'>) { - const key = msg.key, - value = msg.value, - deleted = msg.deleted; - - if ( deleted ) { - this._cached.delete(key); - this.emit('changed', key, undefined, true); - } else { - this._cached.set(key, value); - this.emit('changed', key, value, false); - } - } - - onReply(msg: CorsReplyMessage | CorsReplyErrorMessage) { - const id = msg.id, - success = msg.ffz_type === 'reply', - cbs = this._rpc.get(id); - if ( ! cbs ) - return this.manager.log.warn('Received reply for unknown ID', id); - - this._rpc.delete(id); - if ( success ) - cbs[0](msg.reply); - else - cbs[1](); - } } +// ============================================================================ +// ExtensionProvider +// ============================================================================ + +export class ExtensionProvider extends RemoteSettingsProvider { + + // Static Stuff + + static supported() { return EXTENSION } + + static hasContent() { + if ( ! ExtensionProvider.supported() ) + return false; + + // We need a promise since we need to message the extension and + // request to know if it has keys or not. + return new Promise((resolve) => { + let responded = false, + timeout: ReturnType | null = null ; + + const listener = (evt: MessageEvent) => { + if (evt.source !== window) + return; + + if (evt.data && evt.data.type === 'ffz_from_ext') { + const msg = evt.data.data, + type = msg?.ffz_type; + + if (type === 'has-keys') { + responded = true; + resolve(msg.value); + cleanup(); + } + } + }; + + const cleanup = () => { + if (!responded) { + responded = true; + resolve(false); + } + + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + + window.removeEventListener('message', listener); + } + + window.addEventListener('message', listener); + + window.postMessage({ + type: 'ffz_to_ext', + data: { + ffz_type: 'check-has-keys' + } + }, '*'); + + timeout = setTimeout(cleanup, 1000); + }); + } + + static priority = 101; + static title = 'Browser Extension Storage'; + static description = 'This provider uses a browser extension service worker to store settings in a location that should not suffer from issues due to storage partitioning or cache clearing.'; + + static allowTransfer = true; + static shouldUpdate = true; + + // State and Storage + + constructor(manager: SettingsManager) { + super(manager); + + this.onExtMessage = this.onExtMessage.bind(this); + window.addEventListener('message', this.onExtMessage); + } + + // Stuff + + broadcastTransfer() { + + } + + disableEvents() { + + } + + // Communication + + onExtMessage(evt: MessageEvent) { + if (evt.source !== window) + return; + + if (evt.data?.type === 'ffz_from_ext' && evt.data.data?.ffz_type) + this.handleMessage(evt.data.data); + } + + send(msg: string | CorsMessage, transfer?: OptionalArray) { + if ( typeof msg === 'string' ) + msg = {ffz_type: msg} as any; + + try { + window.postMessage( + { + type: 'ffz_to_ext', + data: msg + }, + '*', + transfer ? (Array.isArray(transfer) ? transfer : [transfer]) : undefined + ); + + } catch(err) { + this.manager.log.error('Error sending message to extension.', err, msg, transfer); + } + } + +} + + + type CorsRpcTypes = { + 'ready': { + input: void; + output: void; + }; + 'load': { input: void; output: Record; @@ -1531,6 +1689,14 @@ type CorsRpcTypes = { output: void; }; + 'change-blob': { + input: { + key: string; + deleted: boolean; + }; + output: void; + } + 'delete-blob': { input: { key: string; @@ -1595,6 +1761,7 @@ export const Providers: Record = { local: LocalStorageProvider, idb: IndexedDBProvider, - cosb: CrossOriginStorageBridge + cosb: CrossOriginStorageBridge, + //ext: ExtensionProvider }; diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index 9ba3f671..19efecb8 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -292,6 +292,15 @@ export default class ChatHook extends Module { // Settings + this.settings.add('chat.subs.native', { + default: false, + ui: { + path: 'Chat > Appearance >> Subscriptions', + title: 'Display subscription notices using Twitch\'s native UI.', + component: 'setting-check-box' + } + }); + this.settings.add('chat.filtering.show-reasons', { default: false, ui: { @@ -1671,7 +1680,7 @@ export default class ChatHook extends Module { return; if ( event.prefix === 'pinned-chat-updates-v1' ) { - this.log.info('Pinned Chat', event); + this.log.debug('Pinned Chat', event); return; } @@ -2686,7 +2695,7 @@ export default class ChatHook extends Module { if ( t.chat.context.get('chat.filtering.blocked-types').has('Subscription') ) return; - if ( t.disable_handling ) + if ( t.disable_handling || t.chat.context.get('chat.subs.native') ) return old_sub.call(i, e); if ( t.chat.context.get('chat.subs.show') < 3 ) @@ -2772,7 +2781,7 @@ export default class ChatHook extends Module { if ( t.chat.context.get('chat.filtering.blocked-types').has('Resubscription') ) return; - if ( t.disable_handling ) + if ( t.disable_handling || t.chat.context.get('chat.subs.native') ) return old_resub.call(i, e); if ( t.chat.context.get('chat.subs.show') < 2 && ! e.body ) @@ -2811,7 +2820,7 @@ export default class ChatHook extends Module { if ( t.chat.context.get('chat.filtering.blocked-types').has('SubGift') ) return; - if ( t.disable_handling ) + if ( t.disable_handling || t.chat.context.get('chat.subs.native') ) return old_subgift.call(i, e); const key = `${e.channel}:${e.user.userID}`, @@ -2887,7 +2896,7 @@ export default class ChatHook extends Module { if ( t.chat.context.get('chat.filtering.blocked-types').has('AnonSubGift') ) return; - if ( t.disable_handling ) + if ( t.disable_handling || t.chat.context.get('chat.subs.native') ) return old_anonsubgift.call(i, e); const key = `${e.channel}:ANON`, @@ -2944,7 +2953,7 @@ export default class ChatHook extends Module { if ( t.chat.context.get('chat.filtering.blocked-types').has('SubMysteryGift') ) return; - if ( t.disable_handling ) + if ( t.disable_handling || t.chat.context.get('chat.subs.native') ) return old_submystery.call(i, e); let mystery = null; @@ -2983,7 +2992,7 @@ export default class ChatHook extends Module { if ( t.chat.context.get('chat.filtering.blocked-types').has('AnonSubMysteryGift') ) return; - if ( t.disable_handling ) + if ( t.disable_handling || t.chat.context.get('chat.subs.native') ) return old_anonsubmystery.call(i, e); let mystery = null; diff --git a/src/sites/twitch-twilight/modules/css_tweaks/index.js b/src/sites/twitch-twilight/modules/css_tweaks/index.js index 5ab87b0c..34048f25 100644 --- a/src/sites/twitch-twilight/modules/css_tweaks/index.js +++ b/src/sites/twitch-twilight/modules/css_tweaks/index.js @@ -54,6 +54,8 @@ const CLASSES = { 'pinned-hype-chat': '.paid-pinned-chat-message-list', + 'side-stories': '.side-nav__title + div[class*=storiesLeftNavSection]', + 'ci-mod-view': '.chat-input__buttons-container a[href*="/moderator"]', 'ci-highlight-settings': '.chat-input__buttons-container button[data-highlight-selector="chat-highlights-shortcut"]', 'ci-shield-mode': '.chat-input__buttons-container > div:last-child button[class|="ScCoreButton"]:not([data-highlight-selector]):not([data-a-target])' @@ -140,6 +142,15 @@ export default class CSSTweaks extends Module { } }); + this.settings.add('layout.side-nav.hide-stories', { + default: false, + ui: { + path: 'Appearance > Layout >> Side Navigation', + title: 'Hide Stories', + component: 'setting-check-box' + } + }); + this.settings.add('layout.side-nav.show', { default: 1, requires: ['layout.use-portrait'], @@ -522,6 +533,7 @@ export default class CSSTweaks extends Module { this.toggleHide('celebration', ! this.settings.get('channel.show-celebrations')); this.settings.getChanges('layout.subtember', val => this.toggleHide('subtember', !val)); + this.settings.getChanges('layout.side-nav.hide-stories', val => this.toggleHide('side-stories', val)); this.updateFont(); this.updateTopNav(); diff --git a/src/sites/twitch-twilight/modules/css_tweaks/styles/minimal-navigation.scss b/src/sites/twitch-twilight/modules/css_tweaks/styles/minimal-navigation.scss index 7ffcb75b..58905936 100644 --- a/src/sites/twitch-twilight/modules/css_tweaks/styles/minimal-navigation.scss +++ b/src/sites/twitch-twilight/modules/css_tweaks/styles/minimal-navigation.scss @@ -10,17 +10,22 @@ transition: top ease-in-out 75ms, bottom ease-in-out 75ms; + .top-nav__search-container, .tw-svg__asset--logotwitch { - visibility: hidden; + transition: opacity ease-in-out 75ms; + opacity: 0; + //visibility: hidden; } &:focus-within, &:hover { top: 0 !important; + .top-nav__search-container, .tw-svg__asset--logotwitch { - visibility: visible; + opacity: 1; + //visibility: visible; } } } -} \ No newline at end of file +} diff --git a/src/sites/twitch-twilight/modules/directory/index.jsx b/src/sites/twitch-twilight/modules/directory/index.jsx index 46e84e9a..996bda36 100644 --- a/src/sites/twitch-twilight/modules/directory/index.jsx +++ b/src/sites/twitch-twilight/modules/directory/index.jsx @@ -211,6 +211,57 @@ export default class Directory extends Module { changed: () => this.DirectoryShelf.forceUpdate() }); + + this.settings.add('directory.block-users', { + default: [], + type: 'array_merge', + always_inherit: true, + ui: { + path: 'Directory > Channels >> Block by Username', + component: 'basic-terms', + words: false + } + }); + + this.settings.add('__filter:directory.block-users', { + requires: ['directory.block-users'], + equals: 'requirements', + process(ctx) { + const val = ctx.get('directory.block-users'); + if ( ! val || ! val.length ) + return null; + + const out = [[], []]; + + for(const item of val) { + const t = item.t; + let v = item.v; + + if ( t === 'glob' ) + v = glob_to_regex(v); + + else if ( t !== 'raw' ) + v = escape_regex(v); + + if ( ! v || ! v.length ) + continue; + + out[item.s ? 0 : 1].push(v); + } + + return [ + out[0].length + ? new RegExp(`^(?:${out[0].join('|')})$`) + : null, + out[1].length + ? new RegExp(`^(?:${out[1].join('|')})$`, 'i') + : null + ]; + }, + + changed: () => this.updateCards() + }); + this.settings.add('directory.block-titles', { default: [], type: 'array_merge', @@ -702,6 +753,19 @@ export default class Directory extends Module { } } + if ( ! should_hide ) { + const regexes = this.settings.get('__filter:directory.block-users'); + if ( regexes ) { + if ( regexes[0] ) + regexes[0].lastIndex = -1; + if ( regexes[1] ) + regexes[1].lastIndex = -1; + + if (( regexes[0] && regexes[0].test(props.channelLogin) ) || ( regexes[1] && regexes[1].test(props.channelLogin) )) + should_hide = true; + } + } + if ( ! should_hide ) { const regexes = this.settings.get('__filter:directory.block-titles'); if ( regexes ) { diff --git a/src/sites/twitch-twilight/modules/layout.js b/src/sites/twitch-twilight/modules/layout.js index e75e01c5..b0e2d5d1 100644 --- a/src/sites/twitch-twilight/modules/layout.js +++ b/src/sites/twitch-twilight/modules/layout.js @@ -383,15 +383,30 @@ export default class Layout extends Module { if ( props?.isPromoted && this.settings.get('directory.hide-promoted') ) should_hide = true; else { - const regexes = this.settings.get('__filter:directory.block-titles'); - const title = stream?.broadcaster?.broadcastSettings?.title; - if ( regexes && title ) { - if ( regexes[0] ) - regexes[0].lastIndex = -1; - if ( regexes[1] ) - regexes[1].lastIndex = -1; - if ( (regexes[0] && regexes[0].test(title)) || (regexes[1] && regexes[1].test(title)) ) - should_hide = true; + if ( ! should_hide ) { + const regexes = this.settings.get('__filter:directory.block-users'); + const login = props.userLogin; + if ( regexes && login ) { + if ( regexes[0] ) + regexes[0].lastIndex = -1; + if ( regexes[1] ) + regexes[1].lastIndex = -1; + if ( (regexes[0] && regexes[0].test(login)) || (regexes[1] && regexes[1].test(login)) ) + should_hide = true; + } + } + + if ( ! should_hide) { + const regexes = this.settings.get('__filter:directory.block-titles'); + const title = stream?.broadcaster?.broadcastSettings?.title; + if ( regexes && title ) { + if ( regexes[0] ) + regexes[0].lastIndex = -1; + if ( regexes[1] ) + regexes[1].lastIndex = -1; + if ( (regexes[0] && regexes[0].test(title)) || (regexes[1] && regexes[1].test(title)) ) + should_hide = true; + } } } diff --git a/src/utilities/dom.ts b/src/utilities/dom.ts index db7feb32..070f232c 100644 --- a/src/utilities/dom.ts +++ b/src/utilities/dom.ts @@ -1,6 +1,6 @@ import {has} from 'utilities/object'; -import type { DomFragment, OptionalArray } from './types'; +import type { DomFragment } from './types'; import { DEBUG } from './constants'; const ATTRS = [ @@ -242,6 +242,9 @@ export function setChildren( no_sanitize: boolean = false, no_empty: boolean = false ) { + if (no_sanitize) + window.FrankerFaceZ.get().log.warn('call to setChildren with no_sanitize set to true -- this is no longer supported'); + if (children instanceof Node ) { if (! no_empty ) element.innerHTML = ''; @@ -260,15 +263,20 @@ export function setChildren( else if (child) { const val = typeof child === 'string' ? child : String(child); - element.appendChild(no_sanitize ? - range.createContextualFragment(val) : document.createTextNode(val)); + // We no longer support no_sanitize + //element.appendChild(no_sanitize ? + // range.createContextualFragment(val) : document.createTextNode(val)); + + element.appendChild(document.createTextNode(val)); } } else if (children) { const val = typeof children === 'string' ? children : String(children); - element.appendChild(no_sanitize ? - range.createContextualFragment(val) : document.createTextNode(val)); + // We no longer support no_sanitize + //element.appendChild(no_sanitize ? + // range.createContextualFragment(val) : document.createTextNode(val)); + element.appendChild(document.createTextNode(val)); } } diff --git a/src/utilities/types.ts b/src/utilities/types.ts index 5256b2f1..86f26576 100644 --- a/src/utilities/types.ts +++ b/src/utilities/types.ts @@ -248,7 +248,14 @@ export interface ExperimentTypeMap { }; export interface ModuleEventMap { + 'main_menu': MainMenuEvents; +}; +export type MainMenuEvents = { + ':open': [event?: { + item?: string; + event?: MouseEvent; + }] }; export interface ModuleMap { diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 00000000..b6ecedcb --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,31 @@ +const browser = ((globalThis as any).browser ?? globalThis.chrome) as typeof globalThis.chrome; + +browser.runtime.onInstalled.addListener(() => { + browser.action.disable(); +}); + +browser.action.onClicked.addListener(tab => { + if ( ! tab?.id ) + return; + + browser.tabs.sendMessage(tab.id, { + type: 'ffz_to_page', + data: { + ffz_type: 'open-settings' + } + }); +}); + +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { + const type = message?.type; + if ( ! type || ! sender?.tab?.id ) + return; + + if ( type === 'ffz_not_supported' ) + browser.action.disable(sender.tab.id); + + else if ( type === 'ffz_injecting' ) + browser.action.enable(sender.tab.id); + + console.log('got message', message, sender); +}); diff --git a/webpack.config.js b/webpack.config.js index 13b38b8c..23ea9fca 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -54,6 +54,20 @@ const ENTRY_POINTS = { clips: './src/clips.js' }; +if ( FOR_EXTENSION ) + ENTRY_POINTS.worker = './src/worker.ts'; + +const COPY_PATTERNS = [ + { + from: FOR_EXTENSION + ? './src/entry_ext.js' + : './src/entry.js', + to: (DEV_SERVER || DEV_BUILD) + ? 'script.js' + : 'script.min.js' + }, +]; + const TARGET = 'es2020'; /** @type {import('webpack').Configuration} */ @@ -125,16 +139,7 @@ const config = { plugins: [ new CopyPlugin({ - patterns: [ - { - from: FOR_EXTENSION - ? './src/entry_ext.js' - : './src/entry.js', - to: (DEV_SERVER || DEV_BUILD) - ? 'script.js' - : 'script.min.js' - } - ] + patterns: COPY_PATTERNS }), new VueLoaderPlugin(), new EsbuildPlugin({