From cf5cb76bc59dccf5477400a7d7fec034215d87d6 Mon Sep 17 00:00:00 2001 From: Yosuke Mizutani Date: Mon, 4 Jan 2021 20:08:36 -0700 Subject: [PATCH] Implement new features for v0.1.1 - Add support for filtering replies - Add support for blocked words - Improve preformance - Improve translation --- assets/_locales/en/messages.json | 18 + assets/_locales/ja/messages.json | 18 + assets/_locales/ko/messages.json | 18 + assets/css/popup.css | 81 +- assets/css/yay-filter.css | 9 +- jest.setup.js | 4 +- package-lock.json | 23 + package.json | 1 + src/App.ts | 129 +- src/Config.ts | 14 +- src/__tests__/dom/DomManager.test.ts | 41 + src/__tests__/model/Settings.test.ts | 33 +- src/__tests__/resources/threads.html | 6451 ++++++++++++++++++++++++++ src/content.ts | 3 + src/dom/DomManager.ts | 86 +- src/model/AppContext.ts | 17 + src/model/CommentInfo.ts | 204 + src/model/OutdatedRequestError.ts | 8 - src/model/Settings.ts | 68 +- src/model/ThreadManager.ts | 270 +- src/popup.ts | 114 +- webpack.config.js | 11 + 22 files changed, 7316 insertions(+), 305 deletions(-) create mode 100644 src/__tests__/dom/DomManager.test.ts create mode 100644 src/__tests__/resources/threads.html create mode 100644 src/model/AppContext.ts create mode 100644 src/model/CommentInfo.ts delete mode 100644 src/model/OutdatedRequestError.ts diff --git a/assets/_locales/en/messages.json b/assets/_locales/en/messages.json index 8b02ee1..d943f23 100644 --- a/assets/_locales/en/messages.json +++ b/assets/_locales/en/messages.json @@ -8,6 +8,9 @@ "enable_by_default": { "message": "Enable filter by default" }, + "filter_replies": { + "message": "Filter replies" + }, "languages": { "message": "Languages" }, @@ -23,6 +26,21 @@ "remove_this_language": { "message": "Remove this language" }, + "blocked_words": { + "message": "Blocked words" + }, + "blocked_words_description": { + "message": "Place each word on a new line." + }, + "clear": { + "message": "Clear" + }, + "save": { + "message": "Save" + }, + "save_blocked_words": { + "message": "Save blocked words" + }, "reset_settings": { "message": "Reset settings" }, diff --git a/assets/_locales/ja/messages.json b/assets/_locales/ja/messages.json index 3ba734b..6814f21 100644 --- a/assets/_locales/ja/messages.json +++ b/assets/_locales/ja/messages.json @@ -8,6 +8,9 @@ "enable_by_default": { "message": "デフォルトで有効" }, + "filter_replies": { + "message": "返信もフィルターする" + }, "languages": { "message": "言語" }, @@ -23,6 +26,21 @@ "remove_this_language": { "message": "この言語を削除" }, + "blocked_words": { + "message": "ブロックする単語" + }, + "blocked_words_description": { + "message": "各行に 1つずつ\nキーワードを記入してください" + }, + "clear": { + "message": "消去" + }, + "save": { + "message": "保存" + }, + "save_blocked_words": { + "message": "ブロックする単語を保存" + }, "reset_settings": { "message": "初期設定に戻す" }, diff --git a/assets/_locales/ko/messages.json b/assets/_locales/ko/messages.json index d486822..dbf07c3 100644 --- a/assets/_locales/ko/messages.json +++ b/assets/_locales/ko/messages.json @@ -8,6 +8,9 @@ "enable_by_default": { "message": "기본으로 활성화" }, + "filter_replies": { + "message": "회신 필터링" + }, "languages": { "message": "언어" }, @@ -23,6 +26,21 @@ "remove_this_language": { "message": "이 언어를 제거" }, + "blocked_words": { + "message": "차단 된 단어" + }, + "blocked_words_description": { + "message": "각 줄에 하나씩 키워드를 입력하십시오" + }, + "clear": { + "message": "맑은" + }, + "save": { + "message": "저장" + }, + "save_blocked_words": { + "message": "차단 된 단어 저장" + }, "reset_settings": { "message": "설정 초기화" }, diff --git a/assets/css/popup.css b/assets/css/popup.css index da66868..2af3207 100644 --- a/assets/css/popup.css +++ b/assets/css/popup.css @@ -40,6 +40,7 @@ header .popup-title-version { footer { background-color: #dddddd; font-size: 12px; + margin-top: 12px; border-top: 1px; padding: 6px 6px; border: 0 solid #040404; @@ -71,33 +72,76 @@ label { -khtml-user-select: none; } -.popup-lang-container { - height: 24px; - margin: 2px 0; +.popup-settings textarea { + font-size: 14px; } -.popup-button-icon-remove { +.popup-settings input[type='button'], +input[type='submit'] { + color: #065fd4; + background-color: #f9f9f9; + border: 1px solid #065fd4; float: right; padding: 0; - color: #606060; - background-color: #ececec; - margin-left: 10px; - width: 24px; height: 24px; - border: 1px solid #909090; border-radius: 4px; + margin-left: 10px; } -.popup-button-icon-add { - float: right; - padding: 0; +.popup-settings input[type='button']:hover, +input[type='submit']:hover { + color: #f9f9f9; + background-color: #0a8dff; +} + +.popup-settings input[type='button']:disabled, +input[type='submit']:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.popup-settings input[type='button']:disabled:hover, +input[type='submit']:disabled:hover { color: #065fd4; background-color: #f9f9f9; - margin-left: 10px; - width: 24px; +} + +#popup-form-blocked-words div { + padding-left: 16px; +} + +.popup-settings textarea { + font-size: 13px; + width: 100%; + box-sizing: border-box; + overflow-x: auto; + overflow-y: auto; +} + +.popup-lang-container { height: 24px; - border: 1px solid #065fd4; - border-radius: 4px; + margin: 2px 0; +} + +.popup-button-icon-remove { + color: #606060 !important; + background-color: #ececec !important; + border: 1px solid #909090 !important; + width: 24px; +} + +.popup-button-icon-remove:hover { + color: #f9f9f9 !important; + background-color: #0a8dff !important; + border: 1px solid #065fd4 !important; +} + +.popup-button-icon-add { + width: 24px; +} + +.popup-button-save { + width: 72px; } .popup-lang-select-container { @@ -109,3 +153,8 @@ label { .popup-lang-select { margin-left: 16px; } + +.popup-button-container { + margin: 6px 0 0 0; + height: 24px; +} diff --git a/assets/css/yay-filter.css b/assets/css/yay-filter.css index 900226d..3ee3232 100644 --- a/assets/css/yay-filter.css +++ b/assets/css/yay-filter.css @@ -13,11 +13,8 @@ -khtml-user-select: none; } -#yay-filter-container div { - display: flex; -} - #yay-filter-container svg { + position: absolute; width: 24px; height: 24px; margin-top: -4px; @@ -25,8 +22,8 @@ fill: var(--yt-spec-text-secondary); } -#yay-filter-container span { - padding-top: 2px; +#yay-filter-label { + margin-left: 28px; } #yay-filter-status { diff --git a/jest.setup.js b/jest.setup.js index 5856211..e814e0c 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,3 +1,5 @@ // Add chrome object to global scope -Object.assign(global, require('jest-chrome')) +Object.assign(global, require('jest-chrome')); +// set debug flag +global.__DEBUG__ = false; diff --git a/package-lock.json b/package-lock.json index a4a58f4..4669fd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1016,6 +1016,17 @@ "pretty-format": "^26.0.0" } }, + "@types/jsdom": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-16.2.5.tgz", + "integrity": "sha512-k/ZaTXtReAjwWu0clU0KLS53dyqZnA8mm+jwKFeFrvufXgICp+VNbskETFxKKAguv0pkaEKTax5MaRmvalM+TA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/parse5": "*", + "@types/tough-cookie": "*" + } + }, "@types/json-schema": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", @@ -1034,6 +1045,12 @@ "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", "dev": true }, + "@types/parse5": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/parse5/-/parse5-5.0.3.tgz", + "integrity": "sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==", + "dev": true + }, "@types/prettier": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.1.6.tgz", @@ -1046,6 +1063,12 @@ "integrity": "sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==", "dev": true }, + "@types/tough-cookie": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==", + "dev": true + }, "@types/yargs": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.12.tgz", diff --git a/package.json b/package.json index 2a735d4..5daaa57 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "devDependencies": { "@types/chrome": "0.0.127", "@types/jest": "^26.0.19", + "@types/jsdom": "^16.2.5", "@typescript-eslint/eslint-plugin": "^4.11.0", "@typescript-eslint/parser": "^4.11.0", "chrome": "^0.1.0", diff --git a/src/App.ts b/src/App.ts index e71d150..06797a1 100644 --- a/src/App.ts +++ b/src/App.ts @@ -2,25 +2,29 @@ import { Config } from './Config'; import DomManager from './dom/DomManager'; import Settings from './model/Settings'; import ThreadManager from './model/ThreadManager'; -import LanguageDetector from './lang/LanguageDetector'; -import OutdatedRequestError from './model/OutdatedRequestError'; +import AppContext from './model/AppContext'; + +/** + * Unexpected URL error. + */ +class UnexpectedUrlError extends Error { + constructor(url: string) { + super(`unexpected URL: ${url}`); + } +} /** * Manages the content-side app. */ export default class App { - /** True if filtering is currently enabled. */ - private enabled = true; - /** Current settings. */ - private settings: Settings = new Settings(); - /** Language detector. */ - private languageDetector = new LanguageDetector(Config.settings.maxLanguageDetectorCacheSize); + /** Application context. */ + private context = new AppContext(); + /** True if reload() has been called. */ + private loaded = false; /** Thread list observer. */ private threadListObserver: MutationObserver; - /** Array of thread managers. */ + /** Map of thread managers. */ private threadManagers = new Map(); - /** Flag for debugging. */ - private debugLogEnabled = false; // HTML Elements private yayFilterStatus: HTMLElement | null = null; @@ -42,10 +46,20 @@ export default class App { this.threadListObserver = new MutationObserver((m, o) => this.handleThreadListUpdate(m, o)); } + /** + * Manually load the app. + */ + load(): void { + if (!this.loaded) this.reload('startup'); + } + /** * Reloads the app. */ reload(reason: string): void { + // this.log(`reload(${reason})`); + + this.loaded = true; Promise.resolve() .then(() => this.verifyUrl(`${window.location.host}${window.location.pathname}`)) .then(() => this.startInitialization()) @@ -56,7 +70,9 @@ export default class App { .then((elem) => this.injectFilterButton(elem)) .then(() => this.refresh()) .catch((error) => { - if (this.debugLogEnabled) console.error(error); + if (!(error instanceof UnexpectedUrlError)) { + if (Config.debug.enabled) console.error(error); + } }); } @@ -97,7 +113,7 @@ export default class App { * @param url URL to check */ private verifyUrl(url: string): void { - if (url != Config.url.targetUrl) throw new Error('unexpected URL'); + if (url != Config.url.targetUrl) throw new UnexpectedUrlError(url); } /** @@ -108,7 +124,7 @@ export default class App { const p = new Promise((resolve, reject) => { Settings.loadFromStorage().then( (s) => { - this.settings = s; + this.context.settings = s; resolve(); }, (error) => reject(error), @@ -121,7 +137,7 @@ export default class App { * Sets the default enabled setting. */ private setEnabledFromDefault(): void { - this.enabled = this.settings.isEnabledDefault(); + this.context.enabled = this.context.settings.isEnabledDefault(); } /** @@ -129,6 +145,8 @@ export default class App { * @return Promise of the comment container */ private findCommentContainer(): Promise { + // this.log('findCommentContainer'); + const p = new Promise((resolve, reject) => { DomManager.withCommentContainer((elem) => { if (elem == null) { @@ -147,6 +165,12 @@ export default class App { * @return Promise of the comment header element */ private findCommentHeader(commentContainer: HTMLElement): Promise { + // this.log('findCommentHeader'); + + // check if it already exists (this can happen by a browser's "go back" button, etc.) + const e = DomManager.findCommentHeader(); + if (e != null) return Promise.resolve(e); + const p = new Promise((resolve, reject) => { const observer = new MutationObserver((m, o) => { // check if the comment header exists @@ -169,6 +193,8 @@ export default class App { * @param commentHeader comment header */ private injectFilterButton(commentHeader: HTMLElement): void { + // this.log('injectFilterButton()'); + // create filter button this.clearYayFilterContainer(); const [c, s, i] = DomManager.createYayFilterContainer(() => this.toggleEnabled()); @@ -187,6 +213,8 @@ export default class App { * Clears outdated information and stops observers. */ private startInitialization(): void { + // this.log('startInitialization'); + this.stopObservers(); this.yayFilterStatus = this.yayFilterInfo = this.threadContainer = null; } @@ -195,17 +223,17 @@ export default class App { * Refreshes all threads. */ private refresh(): void { + if (this.threadContainer == null) return; + this.stopObservers(); this.startObservers(); // observer must start before finding threads // process already-rendendered threads - DomManager.findCommentThreads().forEach((t) => - this.getOrCreateThreadManager(t)[0] - .refreshAll() + DomManager.findCommentThreads(this.threadContainer).forEach((t) => + this.getOrCreateThreadManager(t) + .refreshMain() .catch((error) => { - if (error instanceof OutdatedRequestError) { - // do nothing - } else if (this.debugLogEnabled) console.error(error); + if (Config.debug.enabled) console.error(error); }), ); } @@ -224,6 +252,8 @@ export default class App { * Updates the status labels. */ private refreshStatus(): void { + if (this.threadContainer == null) return; + const st = this.yayFilterStatus; const info = this.yayFilterInfo; @@ -231,16 +261,16 @@ export default class App { // const info = DomManager.findYayFilterInfo(); if (!st || !info) { - // console.log(st, info); + // console.debug(st, info); return; } // FIXME: count incrementally for better performance - const threads = [...DomManager.findCommentThreads()]; + const threads = [...DomManager.findCommentThreads(this.threadContainer)]; const numVisible = threads.filter((e) => e.style.display != 'none').length; - DomManager.replaceText(st, this.enabled ? 'ON' : 'OFF'); - DomManager.replaceText(info, this.enabled ? `(${numVisible} / ${threads.length})` : ''); + DomManager.replaceText(st, this.context.enabled ? 'ON' : 'OFF'); + DomManager.replaceText(info, this.context.enabled ? `(${numVisible} / ${threads.length})` : ''); } //-------------------------------------------------------------------------- @@ -271,12 +301,10 @@ export default class App { // create new thread managers for (const thread of mutation.addedNodes as NodeListOf) { - this.getOrCreateThreadManager(thread)[0] - .refreshAll() + this.getOrCreateThreadManager(thread) + .refreshMain() .catch((error) => { - if (error instanceof OutdatedRequestError) { - // do nothing - } else if (this.debugLogEnabled) console.error(error); + if (Config.debug.enabled) console.error(error); }); } } @@ -286,16 +314,15 @@ export default class App { * Toggles the filtering enabled setting. */ toggleEnabled(): void { - this.enabled = !this.enabled; + this.context.enabled = !this.context.enabled; if (!this.isReady()) return; this.threadManagers.forEach((tm) => tm.refreshFilter().catch((error) => { - if (error instanceof OutdatedRequestError) { - // do nothing - } else if (this.debugLogEnabled) console.error(error); + if (Config.debug.enabled) console.error(error); }), ); + if (this.threadManagers.size == 0) this.refreshStatus(); } /** @@ -303,12 +330,12 @@ export default class App { * @param func function to update settings */ private updateSettings(func: (s: Settings) => Settings): void { - if (!this.enabled) { - this.settings = func(this.settings); + if (!this.context.enabled) { + this.context.settings = func(this.context.settings); } else { - const oldSettings = this.settings.copy(); - this.settings = func(this.settings); - if (this.settings.shouldUpdateLanguageSettings(oldSettings)) { + const oldSettings = this.context.settings.copy(); + this.context.settings = func(this.context.settings); + if (this.context.settings.shouldRefreshFilter(oldSettings)) { this.handleSettingsUpdate(); } } @@ -320,12 +347,11 @@ export default class App { private handleSettingsUpdate(): void { if (!this.isReady()) return; - for (const tm of this.threadManagers.values()) + for (const tm of this.threadManagers.values()) { tm.refreshFilter().catch((error) => { - if (error instanceof OutdatedRequestError) { - // do nothing - } else if (this.debugLogEnabled) console.error(error); + if (Config.debug.enabled) console.error(error); }); + } } //-------------------------------------------------------------------------- @@ -335,21 +361,16 @@ export default class App { /** * Gets or creates a thread manager. * @param thread thread container - * @return tuple of a thread manager and if the manager is new + * @return thread manager */ - private getOrCreateThreadManager(thread: HTMLElement): [ThreadManager, boolean] { + private getOrCreateThreadManager(thread: HTMLElement): ThreadManager { const tm = this.threadManagers.get(thread); if (tm === undefined) { - const t = new ThreadManager( - thread, - () => (this.enabled ? this.settings : null), - (text: string) => this.languageDetector.detectLanguage(text), - () => this.refreshStatus(), - ); + const t = new ThreadManager(this.context, thread, () => this.refreshStatus()); this.threadManagers.set(thread, t); - return [t, true]; + return t; } else { - return [tm, false]; + return tm; } } @@ -358,7 +379,7 @@ export default class App { * @param text text to output */ private log(text: string): void { - if (this.debugLogEnabled) { + if (Config.debug.enabled) { const date = new Date(); console.debug(`[${date.toISOString()}] ${text}`); } diff --git a/src/Config.ts b/src/Config.ts index e355f62..e9f09e9 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -1,6 +1,10 @@ /** * Configurations. */ + +/** Variable defined by webpack.config.js. */ +declare const __DEBUG__: string; + export const Config = { version: '0.1.1', url: { @@ -10,6 +14,7 @@ export const Config = { }, dom: { id: { + yayFilterLabel: 'yay-filter-label', yayFilterContainer: 'yay-filter-container', yayFilterStatus: 'yay-filter-status', yayFilterInfo: 'yay-filter-info', @@ -20,8 +25,12 @@ export const Config = { ytCommentTitle: 'ytd-comments #title.ytd-comments-header-renderer', ytCommentContents: 'ytd-comments #contents.ytd-item-section-renderer', ytCommentThread: 'ytd-comments ytd-comment-thread-renderer', + ytCommentMain: '#comment', + ytCommentReplyRoot: '#replies', + ytCommentReplyContainer: '#replies #loaded-replies', + ytCommentReplyElement: '#replies #loaded-replies ytd-comment-renderer', ytCommentText: '#content-text', - ytCommentSortItems: 'ytd-comments yt-sort-filter-sub-menu-renderer paper-listbox a', + ytCommentTagName: 'ytd-comment-renderer'.toUpperCase(), }, svg: { filterIcon: @@ -33,6 +42,9 @@ export const Config = { defaultPercentageThreshould: 20, maxLanguageDetectorCacheSize: 500, }, + debug: { + enabled: __DEBUG__, + }, // ['mo', 'Moldavian'], // deprecated // ['sh', 'Serbo-Croatian'], // deprecated languages: [ diff --git a/src/__tests__/dom/DomManager.test.ts b/src/__tests__/dom/DomManager.test.ts new file mode 100644 index 0000000..e829f1d --- /dev/null +++ b/src/__tests__/dom/DomManager.test.ts @@ -0,0 +1,41 @@ +import { JSDOM } from 'jsdom'; +import fs from 'fs'; +import path from 'path'; +import DomManager from '../../dom/DomManager'; + +describe('DomManager#fetchTextContent', () => { + const html = fs.readFileSync(path.resolve(__dirname, '../resources/threads.html'), 'utf8'); + const dom = new JSDOM(html); + + beforeAll(async () => { + // + }); + test('some threads and replies', async () => { + const tc = DomManager.getCommentThreadContainer(dom.window.document.body); + const threads = DomManager.findCommentThreads(tc); + + expect(threads.length).toBe(3); + + const getMain = (t: HTMLElement) => DomManager.fetchTextContent(DomManager.getMainCommentContainer(t)); + const getReplies = (t: HTMLElement) => + [...DomManager.findReplyElements(t)].map((elem) => DomManager.fetchTextContent(elem as HTMLElement)); + + // 1 + expect(getMain(threads[0])).toEqual('スレッド1'); + expect(getReplies(threads[0])).toStrictEqual([ + '短い返信。', + 'ネストした返信', + '長めの返信 あ い う え お', + '2番目の返信に対する返信', + '@author1234 返信', + ]); + + // 2 + expect(getMain(threads[1])).toEqual('スレッド3 ...'); + expect(getReplies(threads[1])).toStrictEqual([]); + + // 3 + expect(getMain(threads[2])).toEqual('スレッド2 長文 長文 長文 長文 長文 長文 長文 長文 おわり'); + expect(getReplies(threads[2])).toStrictEqual(['1件の 返信']); + }); +}); diff --git a/src/__tests__/model/Settings.test.ts b/src/__tests__/model/Settings.test.ts index 7dc6c30..23c1395 100644 --- a/src/__tests__/model/Settings.test.ts +++ b/src/__tests__/model/Settings.test.ts @@ -8,16 +8,18 @@ describe('Settings#constructor', () => { Object.assign(Navigator, { languages: () => ['en-US', 'en'] }); let s = new Settings(undefined, undefined, undefined, undefined, undefined, undefined); - expect(s.toJSON()).toBe('{"ed":true,"il":["en"],"iu":false,"ll":["en"],"pt":20,"ew":[]}'); + expect(s.toJSON()).toBe('{"ed":true,"il":["en"],"iu":false,"ll":["en"],"pt":20,"bw":[],"fr":false}'); s = new Settings(undefined, ['ja', 'en', 'de'], undefined, undefined, undefined, undefined); - expect(s.toJSON()).toBe('{"ed":true,"il":["ja","en","de"],"iu":false,"ll":["ja","en","de"],"pt":20,"ew":[]}'); + expect(s.toJSON()).toBe( + '{"ed":true,"il":["ja","en","de"],"iu":false,"ll":["ja","en","de"],"pt":20,"bw":[],"fr":false}', + ); s = new Settings(undefined, undefined, undefined, ['ja', 'de'], undefined, undefined); - expect(s.toJSON()).toBe('{"ed":true,"il":["en"],"iu":false,"ll":["en","ja","de"],"pt":20,"ew":[]}'); + expect(s.toJSON()).toBe('{"ed":true,"il":["en"],"iu":false,"ll":["en","ja","de"],"pt":20,"bw":[],"fr":false}'); s = new Settings(undefined, undefined, undefined, ['ja', 'de', 'en'], undefined, undefined); - expect(s.toJSON()).toBe('{"ed":true,"il":["en"],"iu":false,"ll":["ja","de","en"],"pt":20,"ew":[]}'); + expect(s.toJSON()).toBe('{"ed":true,"il":["en"],"iu":false,"ll":["ja","de","en"],"pt":20,"bw":[],"fr":false}'); s = new Settings( undefined, @@ -28,7 +30,7 @@ describe('Settings#constructor', () => { undefined, ); expect(s.toJSON()).toBe( - '{"ed":true,"il":["es","ja","en"],"iu":false,"ll":["es","ja","de","en"],"pt":20,"ew":[]}', + '{"ed":true,"il":["es","ja","en"],"iu":false,"ll":["es","ja","de","en"],"pt":20,"bw":[],"fr":false}', ); }); }); @@ -40,10 +42,10 @@ describe('Settings#copy', () => { t.setEnabledDefault(true).removeListedLanguage('en').addListedLanguage('fr', true).setPercentageThreshold(18); expect(s.toJSON()).toBe( - '{"ed":false,"il":["en","de","ja"],"iu":false,"ll":["en","de","ja","es"],"pt":15,"ew":["XYZ","abc"]}', + '{"ed":false,"il":["en","de","ja"],"iu":false,"ll":["en","de","ja","es"],"pt":15,"bw":["XYZ","abc"],"fr":false}', ); expect(t.toJSON()).toBe( - '{"ed":true,"il":["de","ja","fr"],"iu":false,"ll":["de","ja","es","fr"],"pt":18,"ew":["XYZ","abc"]}', + '{"ed":true,"il":["de","ja","fr"],"iu":false,"ll":["de","ja","es","fr"],"pt":18,"bw":["XYZ","abc"],"fr":false}', ); }); }); @@ -212,7 +214,7 @@ describe('Settings#shouldFilterByWord', () => { }); }); -describe('Settings#shouldUpdateLanguageSettings', () => { +describe('Settings#shouldRefreshFilter', () => { test('normal cases', () => { const s1 = new Settings(false, ['en'], false, ['en', 'de', 'ja'], 20, []); const s2 = new Settings(true, ['en'], false, ['en', 'de', 'ja'], 20, []); @@ -221,12 +223,15 @@ describe('Settings#shouldUpdateLanguageSettings', () => { const s5 = new Settings(false, ['en', 'de'], true, ['en', 'de', 'ja'], 20, []); const s6 = new Settings(false, ['de', 'en'], true, ['de', 'en', 'ja'], 20, []); const s7 = new Settings(false, ['en'], false, ['en', 'de', 'ja'], 21, []); - expect(s1.shouldUpdateLanguageSettings(s2)).toBeFalsy(); - expect(s2.shouldUpdateLanguageSettings(s3)).toBeFalsy(); - expect(s1.shouldUpdateLanguageSettings(s4)).toBeTruthy(); - expect(s1.shouldUpdateLanguageSettings(s5)).toBeTruthy(); - expect(s5.shouldUpdateLanguageSettings(s6)).toBeFalsy(); - expect(s1.shouldUpdateLanguageSettings(s7)).toBeTruthy(); + const s8 = new Settings(false, ['en'], false, ['en', 'de', 'ja'], 20, [], true); + expect(s1.shouldRefreshFilter(s2)).toBeFalsy(); + expect(s2.shouldRefreshFilter(s3)).toBeFalsy(); + expect(s1.shouldRefreshFilter(s4)).toBeTruthy(); + expect(s1.shouldRefreshFilter(s5)).toBeTruthy(); + expect(s5.shouldRefreshFilter(s6)).toBeFalsy(); + expect(s1.shouldRefreshFilter(s7)).toBeTruthy(); + expect(s1.shouldRefreshFilter(s8)).toBeTruthy(); + expect(s8.shouldRefreshFilter(s1)).toBeTruthy(); }); }); diff --git a/src/__tests__/resources/threads.html b/src/__tests__/resources/threads.html new file mode 100644 index 0000000..353c4fe --- /dev/null +++ b/src/__tests__/resources/threads.html @@ -0,0 +1,6451 @@ + + + +
+ +
+
+ +
+
+ + author1234 + +
+
+ + + +
+ スレッド1 +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+
+ +
+ + + + + Hide 5 replies +
+
+ +
+
+ + author1234 + +
+
+ + + +
+ 短い返信。 +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+ + author1234 + +
+
+ + + +
+ ネストした返信 +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+ + author1234 + +
+
+ + + +
+ 長めの返信 + + + + + + + + + + + + + + +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+ + author1234 + +
+
+ + + +
+ 2番目の返信に対する返信 +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+ + +
+
+ + author1234 + +
+
+ + + +
+ @author1234 返信 +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+ + author1234 + +
+
+ + + +
+ スレッド3 + + ... +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+
+
+
+ + author1234 + +
+
+ + + +
+ スレッド2 + + 長文 + + 長文 + + 長文 + + 長文 + + 長文 + + 長文 + + 長文 + + 長文 + + おわり +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+
+ +
+ + + + + Hide reply +
+
+ +
+
+ + author1234 + +
+
+ + + +
+ 1件の + 返信 +
+ + + +
+ + + +
+
+ + + + +
+ + + + + + +
+ +
+ Reply +
+
+
+
+ + + +
+ +
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+
+
+
+
+
+
diff --git a/src/content.ts b/src/content.ts index a19c5e1..05f3211 100644 --- a/src/content.ts +++ b/src/content.ts @@ -10,6 +10,9 @@ function main(): void { // add event listeners window.addEventListener('load', () => app.reload('load')); window.addEventListener('yt-page-data-updated', () => app.reload('yt-page-data-updated')); + + // manual load (just in case App#reload() is not triggered) + window.setTimeout(() => app.load(), 5000); } main(); diff --git a/src/dom/DomManager.ts b/src/dom/DomManager.ts index d48e3b1..506d9ea 100644 --- a/src/dom/DomManager.ts +++ b/src/dom/DomManager.ts @@ -92,40 +92,67 @@ export default class DomManager { return document.getElementById(Config.dom.id.yayFilterInfo); } + /** + * Gets the thread container. + * @param body document body element + * @return thread container element + */ + static getCommentThreadContainer(body: HTMLElement = document.body): HTMLElement { + return this.getElementByQuery(Config.dom.selector.ytCommentContents, body); + } + /** * Finds all comment threads. * @return list of thread containers */ - static findCommentThreads(): NodeListOf { - return document.querySelectorAll(Config.dom.selector.ytCommentThread); + static findCommentThreads(threadContainer: HTMLElement): NodeListOf { + return threadContainer.querySelectorAll(Config.dom.selector.ytCommentThread); } /** - * Gets the thread container. - * @return thread container element + * Finds all loaded reply elements. + * @param thread thread + * @return list of reply elements + */ + static findReplyElements(thread: HTMLElement): NodeListOf { + return thread.querySelectorAll(Config.dom.selector.ytCommentReplyElement); + } + + /** + * Gets the main comment container. + * @param thread thread + * @return comment container element + */ + static getMainCommentContainer(thread: HTMLElement): HTMLElement { + return this.getElementByQuery(Config.dom.selector.ytCommentMain, thread); + } + + /** + * Finds the reply container of the given thread. + * @param thread thread + * @return reply container or null if not found */ - static getCommentThreadContainer(): HTMLElement { - return this.getElementByQuery(Config.dom.selector.ytCommentContents); + static findReplyContainer(thread: HTMLElement): HTMLElement | null { + return thread.querySelector(Config.dom.selector.ytCommentReplyContainer); } /** - * Fetches the text content of the thread. + * Fetches the text content of the comment element. * @param thread thread container * @return text */ - // FIXME: Resulting text may contain replies static fetchTextContent(thread: HTMLElement): string { const text = thread.querySelector(Config.dom.selector.ytCommentText); - if (text == null || text.textContent == null) return ''; - return text.textContent; + return this.cleanText(text?.textContent); } /** - * Finds the sort options from the page. - * @return list of option containers + * Cleans whitespace in the text. + * @param text input text */ - static findSortItems(): NodeListOf { - return document.querySelectorAll(Config.dom.selector.ytCommentSortItems); + static cleanText(text: string | null | undefined): string { + if (text === undefined || text == null) return ''; + return text.replace(/\s+/g, ' '); } //-------------------------------------------------------------------------- @@ -151,7 +178,9 @@ export default class DomManager { const parser = new DOMParser(); const svg = parser.parseFromString(Config.dom.svg.filterIcon, 'image/svg+xml'); div.appendChild(svg.documentElement); - div.appendChild(this.createElementWithText('span', chrome.i18n.getMessage('filter'))); + const filterLabel = this.createElementWithText('span', chrome.i18n.getMessage('filter')); + filterLabel.id = Config.dom.id.yayFilterLabel; + div.appendChild(filterLabel); const filterStatus = document.createElement('span') as HTMLSpanElement; filterStatus.id = Config.dom.id.yayFilterStatus; div.appendChild(filterStatus); @@ -174,14 +203,10 @@ export default class DomManager { * @param checked checked * @param text text * @param onChanged on changed - * @return tuple of input and label + * @return div element containing input and label elements */ - static createCheckbox( - id: string, - checked: boolean, - text: string, - onChanged: (ev: Event) => void, - ): [HTMLInputElement, HTMLLabelElement] { + static createCheckbox(id: string, checked: boolean, text: string, onChanged: (ev: Event) => void): HTMLDivElement { + const container = document.createElement('div') as HTMLDivElement; const checkbox = document.createElement('input') as HTMLInputElement; checkbox.type = 'checkbox'; checkbox.id = id; @@ -192,7 +217,10 @@ export default class DomManager { label.htmlFor = checkbox.id; label.appendChild(document.createTextNode(text)); - return [checkbox, label]; + container.appendChild(checkbox); + container.appendChild(label); + + return container; } /** @@ -279,6 +307,18 @@ export default class DomManager { return elem; } + /** + * Creates a div element. + * @param innerElems list of child elements + * @param className class name of the div + */ + static createDiv(innerElems: HTMLElement[], className = ''): HTMLDivElement { + const elem = document.createElement('div') as HTMLDivElement; + if (className) elem.className = className; + innerElems.forEach((e) => elem.appendChild(e)); + return elem; + } + //-------------------------------------------------------------------------- // Utilities //-------------------------------------------------------------------------- diff --git a/src/model/AppContext.ts b/src/model/AppContext.ts new file mode 100644 index 0000000..d405bf1 --- /dev/null +++ b/src/model/AppContext.ts @@ -0,0 +1,17 @@ +import { Config } from '../Config'; +import Settings from './Settings'; +import LanguageDetector from '../lang/LanguageDetector'; + +/** + * Defines application context. + */ +export default class AppContext { + /** True if filtering is currently enabled. */ + enabled = true; + + /** Current settings. */ + settings: Settings = new Settings(); + + /** Language detector. */ + languageDetector = new LanguageDetector(Config.settings.maxLanguageDetectorCacheSize); +} diff --git a/src/model/CommentInfo.ts b/src/model/CommentInfo.ts new file mode 100644 index 0000000..71b2e27 --- /dev/null +++ b/src/model/CommentInfo.ts @@ -0,0 +1,204 @@ +import { LanguageDetectorResult } from '../lang/LanguageDetector'; +import DomManager from '../dom/DomManager'; +import PromiseUtil from '../util/PromiseUtil'; +import AppContext from './AppContext'; + +/** + * Outdated request error. + */ +class OutdatedRequestError extends Error { + constructor(requestedAge: number, currentAge: number) { + super(`outdated request: requested=${requestedAge}, current=${currentAge}`); + } +} + +/** + * Manages one comment. + */ +export default class CommentInfo { + /** Application context. */ + private context: AppContext; + /** Reference to the comment container element. */ + private elem: HTMLElement; + /** True if the comment is a reply. */ + private isReply: boolean; + /** True if the extension has already waited for YouTube's rendering. */ + private waited = false; + /** Cached text. Whitespace is compressed. */ + private text = ''; + /** Cached result of the language detection. */ + private detectedLanguage: LanguageDetectorResult | null = null; + /** Keeps track of text generations. */ + private age = 0; + + /** + * Constructs a CommentInfo instance. + * @param context application context + * @param elem comment element + * @param isReply true if the comment is a reply + */ + constructor(context: AppContext, elem: HTMLElement, isReply: boolean) { + this.context = context; + this.elem = elem; + this.isReply = isReply; + } + + /** + * Returns a readable string. + */ + public toString = (): string => { + const t = this.text.length > 20 ? this.text.substring(0, 20) + ' ...' : this.text; + const lang = this.detectedLanguage?.languages.map((l) => `${l.language}->${l.percentage}`); + return `CommentInfo(${t}, langages=${lang}, isReply=${this.isReply})`; + }; + + /** + * Increments the current age and returns it. + * @return incremented age + */ + incrementAge(): number { + return (this.age = (this.age + 1) % 1000000000); + } + + /** + * Analyzes the comment and then applies filering. + * @return Promise of a boolean value that is true if the comment should be hidden + */ + refreshAll(): Promise { + if (this.age < 0) return Promise.resolve(false); + const requestAge = this.incrementAge(); + + return Promise.resolve() + .then(() => this.fetchText(requestAge)) + .then(() => this.detectLanguage(requestAge)) + .then((result) => this.saveLanguage(requestAge, result)) + .then(() => this.applyFilter(requestAge)) + .catch((error) => { + if (!(error instanceof OutdatedRequestError)) throw error; + return false; + }); + } + + /** + * Refreshes filtering. + * @return Promise of a boolean value that is true if the comment should be hidden + */ + refreshFilter(): Promise { + if (this.age < 0) return Promise.resolve(false); + + return this.applyFilter(this.age).catch((error) => { + if (!(error instanceof OutdatedRequestError)) throw error; + return false; + }); + } + + /** + * Safely destroys the comment info. + */ + destroy(): void { + this.age = -1; + } + + //-------------------------------------------------------------------------- + // Tasks + //-------------------------------------------------------------------------- + + /** + * Verifies the requested age. + * @param age requested age + * @throws OutdatedRequestError + */ + private verifyAge(age: number): void { + if (age != this.age) throw new OutdatedRequestError(age, this.age); + } + + /** + * Fetches the comment text from the DOM. + * @param requestAge requested age + */ + private fetchText(requestAge: number): void { + this.verifyAge(requestAge); + + const text = DomManager.fetchTextContent(this.elem); + if (text != this.text) { + this.waited = false; // content has been updated + this.detectedLanguage = null; + this.text = text; + } + } + + /** + * Detectes the language used in the comment text. + * @param age requested age + * @return Promise of a detector result + */ + private detectLanguage(age: number): Promise { + this.verifyAge(age); + + return this.context.languageDetector.detectLanguage(this.text); + } + + /** + * Saves the detected languages. + * @param age requested age + * @param result detection result + */ + private saveLanguage(age: number, result: LanguageDetectorResult): void { + this.verifyAge(age); + + this.detectedLanguage = result; + } + + /** + * Applies a filter to the comment element. + * @param age requested age + * @return Promise of a boolean value that is true if the comment should be hidden + */ + private applyFilter(age: number): Promise { + this.verifyAge(age); + + if (this.detectLanguage == null) return Promise.resolve(false); + + const filtered = this.elem.style.display == 'none'; + const shouldFilter = this.shouldFilter(); + let p = Promise.resolve(shouldFilter); + + if (filtered != shouldFilter) { + if (shouldFilter) { + if (!this.waited) { + // wait for YouTube's rendering before hiding the thread + p = PromiseUtil.delay(300).then(() => { + // console.debug(`applyFilter:ON: ${this.text}`); + this.elem.style.display = 'none'; + this.waited = true; + return true; + }); + } else { + // console.debug(`applyFilter:ON: ${this.text}`); + this.elem.style.display = 'none'; + } + } else { + // console.debug(`applyFilter:OFF: ${this.text}`); + this.elem.style.display = ''; + } + } + + return p; + } + + //-------------------------------------------------------------------------- + // Filtering Logic + //-------------------------------------------------------------------------- + + /** + * Returns if the comment should be filtered. + * @return true if the comment should be hidden + */ + private shouldFilter(): boolean { + if (this.detectedLanguage == null) return false; + if (!this.context.enabled) return false; // filter off + const settings = this.context.settings; + if (this.isReply && !settings.getFilterReplies()) return false; // all replies are visible + return settings.shouldFilterByLanguage(this.detectedLanguage) || settings.shouldFilterByWord(this.text); + } +} diff --git a/src/model/OutdatedRequestError.ts b/src/model/OutdatedRequestError.ts deleted file mode 100644 index 83383d6..0000000 --- a/src/model/OutdatedRequestError.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Outdated request error. - */ -export default class OutdatedRequestError extends Error { - constructor(requestedAge: number, currentAge: number) { - super(`outdated request: requested=${requestedAge}, current=${currentAge}`); - } -} diff --git a/src/model/Settings.ts b/src/model/Settings.ts index 8a8efd0..5f3bfad 100644 --- a/src/model/Settings.ts +++ b/src/model/Settings.ts @@ -18,7 +18,9 @@ export default class Settings { /** True if the comment with unknown languages should be visible. */ private includeUnknownLanguage: boolean; /** Words to exclude. */ - private excludeWords: string[]; + private blockedWords: string[]; + /** True if reply comments should be filtered. */ + private filterReplies: boolean; /** * Constructs settings. @@ -36,6 +38,7 @@ export default class Settings { listedLanguages?: Array | undefined, percentageThreshold?: number | undefined, excludeWords?: string[] | undefined, + filterReplies?: boolean | undefined, ) { // enabled default: default true this.enabledDefault = enabledDefault === undefined ? true : enabledDefault; @@ -64,7 +67,10 @@ export default class Settings { percentageThreshold === undefined ? Config.settings.defaultPercentageThreshould : percentageThreshold; // exclude words: default empty - this.excludeWords = excludeWords === undefined ? [] : excludeWords; + this.blockedWords = excludeWords === undefined ? [] : excludeWords; + + // filter replies: default false + this.filterReplies = filterReplies === undefined ? false : filterReplies; } /** @@ -78,7 +84,8 @@ export default class Settings { this.includeUnknownLanguage, [...this.listedLanguages], this.percentageThreshold, - [...this.excludeWords], + [...this.blockedWords], + this.filterReplies, ); } @@ -97,7 +104,8 @@ export default class Settings { iu: this.includeUnknownLanguage, ll: this.listedLanguages, pt: this.percentageThreshold, - ew: this.excludeWords, + bw: this.blockedWords, + fr: this.filterReplies, }); } @@ -110,7 +118,7 @@ export default class Settings { if (s === undefined) return new Settings(); const obj = JSON.parse(s); - return new Settings(obj.ed, obj.il, obj.iu, obj.ll, obj.pt, obj.ew); + return new Settings(obj.ed, obj.il, obj.iu, obj.ll, obj.pt, obj.bw, obj.fr); } /** @@ -256,12 +264,51 @@ export default class Settings { /** * Updates the percentage threshold. * @param value new percentage threshold + * @return updated settings */ setPercentageThreshold(value: number): Settings { this.percentageThreshold = Math.max(0, Math.min(100, value)); return this; } + /** + * Returns if replies should be filtered. + * @return filter replies + */ + getFilterReplies(): boolean { + return this.filterReplies; + } + + /** + * Updates if replies should be filtered. + * @param value new filter replies + * @return updated settings + */ + setFilterReplies(value: boolean): Settings { + this.filterReplies = value; + return this; + } + + /** + * Returns the list of blocked words. + * @return blocked word list + */ + getBlockedWords(): string[] { + return this.blockedWords; + } + + /** + * Updates the list of blocked words. + * @param words word list + * @return updated settings + */ + setBlockedWords(words: string[]): Settings { + // clean whitespace + const cleaned = words.map((s) => s.replace(/\s+/g, ' ').trim()).filter((s) => s); + this.blockedWords = cleaned; + return this; + } + //-------------------------------------------------------------------------- // Filtering Logic //-------------------------------------------------------------------------- @@ -299,17 +346,22 @@ export default class Settings { */ shouldFilterByWord(text: string): boolean { const t = text.toLocaleLowerCase(); - return this.excludeWords.some((w) => t.includes(w.toLocaleLowerCase())); + return this.blockedWords.some((w) => t.includes(w.toLocaleLowerCase())); } /** - * Determines if the content side needs to re-render all threads by comparing with old settings. + * Determines if the content side needs to refresh filtering by comparing with old settings. * @param oldSettings old settings + * @return true if the content script should refresh */ - shouldUpdateLanguageSettings(oldSettings: Settings): boolean { + shouldRefreshFilter(oldSettings: Settings): boolean { if (!SetUtil.equals(oldSettings.includeLanguages, this.includeLanguages)) return true; if (oldSettings.includeUnknownLanguage !== this.includeUnknownLanguage) return true; if (oldSettings.percentageThreshold !== this.percentageThreshold) return true; + + // FIXME: improve performance? + if (!SetUtil.equals(new Set(oldSettings.blockedWords), new Set(this.blockedWords))) return true; + if (oldSettings.filterReplies !== this.filterReplies) return true; return false; } } diff --git a/src/model/ThreadManager.ts b/src/model/ThreadManager.ts index 30dc4bc..efe380a 100644 --- a/src/model/ThreadManager.ts +++ b/src/model/ThreadManager.ts @@ -1,33 +1,26 @@ -import { LanguageDetectorResult } from '../lang/LanguageDetector'; import DomManager from '../dom/DomManager'; -import Settings from './Settings'; -import PromiseUtil from '../util/PromiseUtil'; -import OutdatedRequestError from './OutdatedRequestError'; +import { Config } from '../Config'; +import AppContext from './AppContext'; +import CommentInfo from './CommentInfo'; /** * Manages one comment thread. */ export default class ThreadManager { - /** True if the language detector has already processed this thread. */ - private parsed = false; - /** True if the extension has already waited for YouTube's rendering. */ - private waited = false; - /** True if the thread is currently hidden. */ - private isFiltered; + /** Application context. */ + private context: AppContext; /** Reference to the thread container element. */ private elem: HTMLElement; - /** Cached result of the language detection. */ - private detectedLanguages: LanguageDetectorResult | null = null; - /** Text content observer. */ - private observer: MutationObserver; - /** Keeps track of refresh timings. */ - private age = 0; - /** Part of the text. */ - private text = ''; + /** Main comment observer. */ + private mainCommentObserver: MutationObserver; + /** Reply comment observer. */ + private replyCommentObserver: MutationObserver; + /** Main comment information. */ + private mainComment: CommentInfo; + /** Map of reply information. */ + private replies = new Map(); // Functions - private getSettings: () => Settings | null; - private detectLanguageFunc: (text: string) => Promise; private refreshStatusFunc: () => void; /** @@ -36,74 +29,67 @@ export default class ThreadManager { * @param getSettings * @param detectLanguage */ - constructor( - elem: HTMLElement, - getSettings: () => Settings | null, - detectLanguage: (text: string) => Promise, - refreshStatus: () => void, - ) { + constructor(context: AppContext, elem: HTMLElement, refreshStatus: () => void) { + this.context = context; this.elem = elem; - this.getSettings = getSettings; - this.detectLanguageFunc = detectLanguage; this.refreshStatusFunc = refreshStatus; - // get the current filtering status - this.isFiltered = elem.style.display == 'none'; + // main comment + this.mainComment = new CommentInfo(this.context, elem, false); - // start observer - this.observer = new MutationObserver(() => this.refreshAll()); - this.observer.observe(this.elem, { subtree: true, characterData: true }); + // set up observers + this.mainCommentObserver = new MutationObserver((m, o) => this.handleMainCommentUpdate(m, o)); + this.replyCommentObserver = new MutationObserver((m, o) => this.handleReplyUpdate(m, o)); } - /** - * Refreshes the thread manager. - * @return Promise of void - */ - refreshAll(): Promise { - if (this.age < 0) return Promise.resolve(); // already destroyed - - this.parsed = false; - const requestAge = this.incrementAge(); - - return Promise.resolve() - .then(() => this.fetchText(requestAge)) - .then((text) => this.detectLanguage(requestAge, text)) - .then((result) => this.saveDetectedLanguages(requestAge, result)) - .then(() => this.applyFilter(requestAge, this.getSettings())) - .then(() => this.refreshStatus(requestAge)); - } + //-------------------------------------------------------------------------- + // Event Handlers + //-------------------------------------------------------------------------- /** - * Refreshes filtering. - * @return Promise of void + * Handles the update of the main comment. + * @param mutations mutation list + * @param observer observer */ - refreshFilter(): Promise { - if (this.age < 0) return Promise.resolve(); // already destroyed - if (!this.parsed) return Promise.resolve(); // skip if refreshAll() is still working - - const requestAge = this.incrementAge(); - - return Promise.resolve() - .then(() => this.applyFilter(requestAge, this.getSettings())) - .then(() => this.refreshStatus(requestAge)); + private handleMainCommentUpdate(mutations: MutationRecord[], observer: MutationObserver): void { + this.refreshMain().catch((error) => { + if (Config.debug.enabled) console.error(error); + }); } /** - * Increments the current age and returns it. - * @return incremented age + * Handles the update of replies. + * @param mutations mutation list */ - incrementAge(): number { - return (this.age = (this.age + 1) % 1000000000); - } + private handleReplyUpdate(mutations: MutationRecord[], observer: MutationObserver): void { + for (const mutation of mutations) { + if (mutation.type != 'childList') continue; + + // clean outdated replies + for (const elem of mutation.removedNodes as NodeListOf) { + if (elem.tagName !== Config.dom.selector.ytCommentTagName) continue; + const r = this.replies.get(elem); + if (r !== undefined) { + r.destroy(); + this.replies.delete(elem); + } + } - /** - * Safely destroy this thread manager. - */ - destroy(): void { - if (this.observer) this.observer.disconnect(); - this.detectedLanguages = null; - this.parsed = false; - this.age = -1; + // add new replies + const ps = []; + for (const elem of mutation.addedNodes as NodeListOf) { + if (elem.tagName !== Config.dom.selector.ytCommentTagName) continue; + const r = this.replies.get(elem); + if (r === undefined) { + const rr = new CommentInfo(this.context, elem, true); + this.replies.set(elem, rr); + ps.push(rr.refreshAll()); + } + } + Promise.all(ps).catch((error) => { + if (Config.debug.enabled) console.error(error); + }); + } } //-------------------------------------------------------------------------- @@ -111,87 +97,79 @@ export default class ThreadManager { //-------------------------------------------------------------------------- /** - * Fetches text from the thread. - * @param age requested age - * @return Promise of the text content - */ - private fetchText(age: number): Promise { - if (age != this.age) throw new OutdatedRequestError(age, this.age); - const text = DomManager.fetchTextContent(this.elem); - this.text = text.substring(0, 20).replace(/\s/g, ' '); - // console.debug(`fetchText() => ${this.text} ...`); - return Promise.resolve(text); - } - - /** - * Detectes the language used in the given text. - * @param age requested age - * @param text text to analyze - * @return Promise of a detector result + * Refreshes the entire thread. + * @return Promise of void */ - private detectLanguage(age: number, text: string): Promise { - if (age != this.age) throw new OutdatedRequestError(age, this.age); - return this.detectLanguageFunc(text); + refreshMain(): Promise { + this.mainCommentObserver.disconnect(); + this.mainCommentObserver.observe(DomManager.getMainCommentContainer(this.elem), { + subtree: true, + characterData: true, + }); + // console.debug(`refreshMain: ${this.mainComment}`); + + return this.mainComment + .refreshAll() + .then((filtered) => { + // console.debug(`refreshMain filter=${filtered}: ${this.mainComment}`); + + // stop observer + this.replyCommentObserver.disconnect(); + + // reset replies + [...this.replies.values()].forEach((r) => r.destroy()); + this.replies.clear(); + + // observe replies + const replyContainer = DomManager.findReplyContainer(this.elem); + if (replyContainer == null) { + // console.debug(`No replies: ${this.mainComment}`); + return; // no replies + } + this.replyCommentObserver.observe(replyContainer, { subtree: false, childList: true }); + // console.debug(`Observe: ${this.mainComment}`); + + // refresh already-rendered replies only if the thread is visible + if (!filtered) { + const ps = []; + + for (const elem of DomManager.findReplyElements(this.elem)) { + const r = new CommentInfo(this.context, elem, true); + ps.push(r.refreshAll()); + this.replies.set(elem, r); + } + return Promise.all(ps); + } + }) + .then(() => this.refreshStatusFunc()); } /** - * Saves the detected languages. - * @param age requested age - * @param result detection result + * Refreshes filtering. + * @return Promise of void */ - private saveDetectedLanguages(age: number, result: LanguageDetectorResult): void { - if (age != this.age) throw new OutdatedRequestError(age, this.age); - this.detectedLanguages = result; - this.parsed = true; + refreshFilter(): Promise { + // console.debug(`refreshFilter: ${this.mainComment}`); + + return this.mainComment + .refreshFilter() + .then((filtered) => { + if (filtered) return; + return Promise.all([...this.replies.values()].map((r) => r.refreshFilter())); + }) + .then(() => this.refreshStatusFunc()); } /** - * Applies a filter to the thread. - * @param age requested age - * @param settings settings to apply - * @param enabled true if filtering is enabled - * @return Promise of the tuple of old and current filtering states + * Safely destroy this thread manager. */ - private applyFilter(age: number, settings: Settings | null): Promise { - if (age != this.age) throw new OutdatedRequestError(age, this.age); - - const oldFiltered = this.isFiltered; - - const shouldFilter = - settings != null && - this.detectedLanguages != null && - settings.shouldFilterByLanguage(this.detectedLanguages); - this.isFiltered = shouldFilter; - - let p = Promise.resolve(); - - if (oldFiltered != shouldFilter) { - if (shouldFilter) { - if (!this.waited) { - // wait for YouTube's rendering before hiding the thread - p = PromiseUtil.delay(300).then(() => { - // console.debug(`applyFilter:ON: ${this.text}`); - this.elem.style.display = 'none'; - this.waited = true; - }); - } else { - // console.debug(`applyFilter:ON: ${this.text}`); - this.elem.style.display = 'none'; - } - } else { - // console.debug(`applyFilter:OFF: ${this.text}`); - this.elem.style.display = ''; - } - } - return p; - } + destroy(): void { + // stop observers + if (this.mainCommentObserver) this.mainCommentObserver.disconnect(); + if (this.replyCommentObserver) this.replyCommentObserver.disconnect(); - /** - * Refreshes status. - * @param age requested age - */ - private refreshStatus(age: number): void { - if (age != this.age) throw new OutdatedRequestError(age, this.age); - this.refreshStatusFunc(); + // destroy comment info + this.mainComment.destroy(); + [...this.replies.values()].forEach((r) => r.destroy()); } } diff --git a/src/popup.ts b/src/popup.ts index 7784d99..ca7a9bd 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -18,6 +18,12 @@ class PopupApp { private sectionLanguagesList: HTMLDivElement; /** Reference to the language select. */ private sectionLanguagesSelect: HTMLSelectElement; + /** Reference to the blocked words form. */ + private formBlockedWords: HTMLFormElement; + /** Reference to the blocked words input. */ + private blockedWordsInput: HTMLTextAreaElement; + /** Reference to the blocked words save button. */ + private buttonSaveBlockedWords: HTMLInputElement; /** * Constructs the app. @@ -71,21 +77,31 @@ class PopupApp { this.sectionLanguagesList = document.createElement('div') as HTMLDivElement; - const selectContainer = document.createElement('div') as HTMLDivElement; - selectContainer.className = 'popup-lang-select-container'; this.sectionLanguagesSelect = this.createLanguageSelect(); const buttonAddLanguage = this.createLanguageAddButton(); - - selectContainer.appendChild(this.sectionLanguagesSelect); - selectContainer.appendChild(buttonAddLanguage); - this.formLanguages.appendChild(this.sectionLanguagesList); this.formLanguages.appendChild(this.createUnknownLanguage()); - this.formLanguages.appendChild(selectContainer); + this.formLanguages.appendChild( + DomManager.createDiv([this.sectionLanguagesSelect, buttonAddLanguage], 'popup-lang-select-container'), + ); settingsDiv.appendChild(DomManager.createElementWithText('h4', chrome.i18n.getMessage('languages'))); settingsDiv.appendChild(this.formLanguages); + // (3) Blocked Words + this.formBlockedWords = DomManager.createForm(() => this.saveBlockedWords(this.blockedWordsInput.value)); + this.formBlockedWords.id = 'popup-form-blocked-words'; + this.blockedWordsInput = this.createBlockedWordsTextArea(); + this.buttonSaveBlockedWords = this.createBlockedWordsSaveButton(); + + this.formBlockedWords.appendChild(DomManager.createDiv([this.blockedWordsInput])); + this.formBlockedWords.appendChild( + DomManager.createDiv([this.buttonSaveBlockedWords], 'popup-button-container'), + ); + + settingsDiv.appendChild(DomManager.createElementWithText('h4', chrome.i18n.getMessage('blocked_words'))); + settingsDiv.appendChild(this.formBlockedWords); + // add to the main element mainElem.appendChild(header); mainElem.appendChild(settingsDiv); @@ -95,6 +111,26 @@ class PopupApp { this.render(this.settings); } + private createBlockedWordsTextArea(): HTMLTextAreaElement { + const elem = document.createElement('textarea') as HTMLTextAreaElement; + elem.placeholder = chrome.i18n.getMessage('blocked_words_description'); + elem.rows = 3; + elem.wrap = 'off'; + elem.maxLength = 500; + elem.addEventListener('input', () => this.renderBlockedWordsAddButton()); + + return elem; + } + + private createBlockedWordsSaveButton(): HTMLInputElement { + const elem = DomManager.createSubmit( + chrome.i18n.getMessage('save'), + chrome.i18n.getMessage('save_blocked_words'), + ); + elem.className = 'popup-button-save'; + return elem; + } + /** * Clears all dynamic HTML elements. */ @@ -109,14 +145,22 @@ class PopupApp { */ private render(settings: Settings): void { // general settings - const [checkbox, label] = DomManager.createCheckbox( - 'chk-enabled-default', - settings.isEnabledDefault(), - chrome.i18n.getMessage('enable_by_default'), - (ev: Event) => this.updateEnabledDefault((ev.target as HTMLInputElement).checked), + this.sectionGeneral.appendChild( + DomManager.createCheckbox( + 'chk-enabled-default', + settings.isEnabledDefault(), + chrome.i18n.getMessage('enable_by_default'), + (ev: Event) => this.updateEnabledDefault((ev.target as HTMLInputElement).checked), + ), + ); + this.sectionGeneral.appendChild( + DomManager.createCheckbox( + 'chk-filter-replies', + settings.getFilterReplies(), + chrome.i18n.getMessage('filter_replies'), + (ev: Event) => this.updateFilterReplies((ev.target as HTMLInputElement).checked), + ), ); - this.sectionGeneral.appendChild(checkbox); - this.sectionGeneral.appendChild(label); // listed languages this.settings.getListedLanguages().forEach((lang) => this.renderAddedLanguage(lang)); @@ -124,6 +168,10 @@ class PopupApp { // set unknown language const elem = document.getElementById('chk-lang-unknown') as HTMLInputElement; if (elem !== undefined) elem.checked = this.settings.getIncludeUnknown(); + + // blocked words + this.blockedWordsInput.value = this.settings.getBlockedWords().join('\n'); + this.renderBlockedWordsAddButton(); } /** @@ -166,19 +214,14 @@ class PopupApp { * Creates the unknown language checkbox with a label. */ private createUnknownLanguage(): HTMLDivElement { - const container = document.createElement('div') as HTMLDivElement; - - container.id = 'lang-unknown'; - container.className = 'popup-lang-container'; - const [checkbox, label] = DomManager.createCheckbox( + const container = DomManager.createCheckbox( 'chk-lang-unknown', this.settings.getIncludeUnknown(), chrome.i18n.getMessage('unknown'), (ev: Event) => this.updateLanguageUnknown((ev.target as HTMLInputElement).checked), ); - container.appendChild(checkbox); - container.appendChild(label); - + container.id = 'lang-unknown'; + container.className = 'popup-lang-container'; return container; } @@ -193,17 +236,14 @@ class PopupApp { const containerId = `lang-${languageCode}`; if (document.getElementById(containerId) != null) return; // already included - const container = document.createElement('div') as HTMLDivElement; - container.id = containerId; - container.className = 'popup-lang-container'; - const [checkbox, label] = DomManager.createCheckbox( + const container = DomManager.createCheckbox( `chk-lang-${languageCode}`, this.settings.getIncludeLanguages().has(languageCode), description, (ev: Event) => this.updateLanguageSettings(languageCode, (ev.target as HTMLInputElement).checked), ); - container.appendChild(checkbox); - container.appendChild(label); + container.id = containerId; + container.className = 'popup-lang-container'; container.appendChild(this.createLanguageRemoveButton(languageCode)); this.sectionLanguagesList.appendChild(container); @@ -218,6 +258,11 @@ class PopupApp { if (elem != null) elem.remove(); } + private renderBlockedWordsAddButton(): void { + this.buttonSaveBlockedWords.disabled = + this.settings.getBlockedWords().join('\n') === this.blockedWordsInput.value; + } + //-------------------------------------------------------------------------- // Event Handlers //-------------------------------------------------------------------------- @@ -230,6 +275,13 @@ class PopupApp { this.settings.setEnabledDefault(value).saveToStorage(); } + /** + * Handles a change of the filter replies. + * @param value new value + */ + private updateFilterReplies(value: boolean): void { + this.settings.setFilterReplies(value).saveToStorage(); + } /** * Handles a change of the language setting. * @param languageCode language code @@ -265,6 +317,12 @@ class PopupApp { this.renderRemovedLanguage(languageCode); } + private saveBlockedWords(blockedWordsText: string): void { + this.buttonSaveBlockedWords.disabled = true; + this.settings.setBlockedWords(blockedWordsText.split('\n')).saveToStorage(); + this.blockedWordsInput.value = this.settings.getBlockedWords().join('\n'); + } + /** * Resets to the default settings. */ diff --git a/webpack.config.js b/webpack.config.js index 3b5b693..361f01d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,10 @@ 'use strict'; +var webpack = require('webpack'); + +// Debug flag +const debugEnabled = process.env.NODE_ENV === 'development'; + module.exports = { // Set debugging source maps to be "inline" for // simplicity and ease of use @@ -37,4 +42,10 @@ module.exports = { resolve: { extensions: ['.ts', '.js'], }, + + plugins: [ + new webpack.DefinePlugin({ + __DEBUG__: JSON.stringify(debugEnabled), + }), + ], };