diff --git a/.eslintrc.json b/.eslintrc.json index 06843a4..7a05b58 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,6 @@ { "extends": [ - "@abhijithvijayan/eslint-config/typescript", + // "@abhijithvijayan/eslint-config/typescript", // garbage "@abhijithvijayan/eslint-config/node", "@abhijithvijayan/eslint-config/react", "plugin:no-unsanitized/DOM" @@ -25,7 +25,13 @@ } ], "no-unsanitized/method": "error", - "no-unsanitized/property": "error" + "no-unsanitized/property": "error", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "off", + // "prettier/prettier": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "node/no-unpublished-require": "off" }, "env": { "webextensions": true diff --git a/README.md b/README.md index 1a98843..73239db 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ Have you ever had to reread entire 200+ comments Reddit thread just to find a fe Chrome/Firefox extension for easier tracking of new comments on Reddit. Free, open source, privacy aware, runs completely client side without sending any data to any server. +#### Update 2024 + +Around January 2024. Reddit moved all users to the new design and made version `v0.0.4` outdated. In April 2024. I updated the extension to `1.0.0` to support the new design, and that is the only Reddit design that is supported (available on `www.reddit.com`). + +The screenshot bellow shows the new design, demo video shows deprecated design - all features are the same, it shouldn't be a problem. + +Version `1.0.0` also includes `Immediately` checkbox to mark the current thread as read manually. + ## Screenshots ![Screenshot_1](/docs/screenshots/Screenshot_1.png) @@ -41,19 +49,19 @@ Chrome/Firefox extension for easier tracking of new comments on Reddit. Free, op ### Install manually -Go to release page [https://github.com/nemanjam/reddit-unread-comments/releases/tag/v0.0.4](https://github.com/nemanjam/reddit-unread-comments/releases/tag/v0.0.4) and download Firefox `.xpi` or Chrome `.zip`. +Go to release page [https://github.com/nemanjam/reddit-unread-comments/releases/tag/1.0.0](https://github.com/nemanjam/reddit-unread-comments/releases/tag/1.0.0) and download Firefox `.xpi` or Chrome `.zip`. - **Firefox manual install:** - - In Firefox click `Settings` (three horizontal lines in the top-right corner), click `Extensions tab`, click `Gear` icon right from `Manage Your Extensions`, choose `Install Add-on From File...` from the menu and browse `reddit-unread-comments-v0.0.4-firefox.xpi` file which you can download from the release page. + - In Firefox click `Settings` (three horizontal lines in the top-right corner), click `Extensions tab`, click `Gear` icon right from `Manage Your Extensions`, choose `Install Add-on From File...` from the menu and browse `reddit-unread-comments-1.0.0-firefox.xpi` file which you can download from the release page. - - **Firefox `.xpi`:** [reddit-unread-comments-v0.0.4-firefox.xpi](https://github.com/nemanjam/reddit-unread-comments/releases/download/v0.0.4/reddit-unread-comments-v0.0.4-firefox.xpi) + - **Firefox `.xpi`:** [reddit-unread-comments-1.0.0-firefox.xpi](https://github.com/nemanjam/reddit-unread-comments/releases/download/1.0.0/reddit-unread-comments-1.0.0-firefox.xpi) - **Chrome manual install:** - - In Chrome navigate to `chrome://extensions/`, switch `Enable developer mode` to true, click `Load unpacked` and browse `reddit-unread-comments-v0.0.4-chrome.zip` file which you can download from the release page. + - In Chrome navigate to `chrome://extensions/`, switch `Enable developer mode` to true, click `Load unpacked` and browse `reddit-unread-comments-1.0.0-chrome.zip` file which you can download from the release page. - - **Chrome `.zip`:** [reddit-unread-comments-v0.0.4-chrome.zip](https://github.com/nemanjam/reddit-unread-comments/releases/download/v0.0.4/reddit-unread-comments-v0.0.4-chrome.zip) + - **Chrome `.zip`:** [reddit-unread-comments-1.0.0-chrome.zip](https://github.com/nemanjam/reddit-unread-comments/releases/download/1.0.0/reddit-unread-comments-1.0.0-chrome.zip) ## Usage diff --git a/docs/screenshots/Screenshot_1.png b/docs/screenshots/Screenshot_1.png index b8f931c..862c14e 100644 Binary files a/docs/screenshots/Screenshot_1.png and b/docs/screenshots/Screenshot_1.png differ diff --git a/docs/screenshots/Screenshot_1_old_Reddit.png b/docs/screenshots/Screenshot_1_old_Reddit.png new file mode 100644 index 0000000..b8f931c Binary files /dev/null and b/docs/screenshots/Screenshot_1_old_Reddit.png differ diff --git a/docs/work-notes/test-code2.ts b/docs/work-notes/test-code2.ts new file mode 100644 index 0000000..ac8058d --- /dev/null +++ b/docs/work-notes/test-code2.ts @@ -0,0 +1,71 @@ + + +const commentId = 't1_kznmxco'; + +const commentSelector = 'shreddit-comment[thingid^="t1_"]'; + +const getCommentSelectorById = (commentId) => `shreddit-comment[thingid^="${commentId}"]`; + +const getTimestampSelectorById = (commentId) => { + const targetedCommentSelector = getCommentSelectorById(commentId); + // avoid nested comments + const timestampSelector = `${targetedCommentSelector} *:not(${commentSelector}) time:first-child[datetime]`; + return timestampSelector; +}; + +const timestampSelector = getTimestampSelectorById(commentId); + +document.querySelectorAll(timestampSelector); + +document.querySelectorAll( + 'shreddit-comment[thingid^="t1_kznmxco"] :not(shreddit-comment) time:first-child[datetime]' +); + +document.querySelectorAll( + 'shreddit-comment[thingid^="t1_kznmxco"] > div[slot="commentMeta"] time' +); + + +document.querySelectorAll('shreddit-comment[thingid^="t1_kzklld1"]'); +document.querySelector( + 'shreddit-comment[thingid^="t1_kzklld1"] time:first-child[datetime]' +); + +const shadowHost = document.getElementById('shadow-host'); +const shadowRoot = shadowHost.getRootNode(); +const buttonInsideShadow = shadowRoot.querySelector('button'); +buttonInsideShadow.click(); + + +document.querySelector('shreddit-sort-dropdown').click() + + +document.querySelector('#comment-sort-button').click() + + +document.querySelectorAll('shreddit-sort-dropdown') + + + +var shadowHost = document.querySelector('shreddit-sort-dropdown'); +var buttonInsideShadow = shadowHost.shadowRoot.querySelector('button[id="comment-sort-button"] > span > span'); +buttonInsideShadow.textContent + +buttonInsideShadow.click(); + + +document.querySelector('button[id="comment-sort-button"]'); + + +document.querySelector('data[value="NEW"]').click() + +document.querySelector('#main-content').click() + +------------- + +var currentElement = document.querySelector('shreddit-comment[thingid^="t1_kz4bckd"]') +var contentElement = currentElement.querySelector('shreddit-comment[thingid^="t1_kz4bckd"] > div[slot="comment"]') +contentElement; + + + diff --git a/docs/work-notes/todo.md b/docs/work-notes/todo.md index 3736d34..590a094 100644 --- a/docs/work-notes/todo.md +++ b/docs/work-notes/todo.md @@ -134,3 +134,9 @@ git tag -a v0.0.4 -m "Release 0.0.4" git push all v0.0.4 ---------- add mark thread as read button + + +git checkout -b feature/new-reddit-design + +git push -u origin feature/new-reddit-design + diff --git a/docs/work-notes/todo2.md b/docs/work-notes/todo2.md new file mode 100644 index 0000000..830dbd6 --- /dev/null +++ b/docs/work-notes/todo2.md @@ -0,0 +1,32 @@ + break dom into multiple files + remove modal from events + parse exact time from time.datetime iso string +put config vars into single object + mark as read label in popup ui +review delay values + fix sort by new selector +wrapper for querySelector that returns Node or throws, not null + scroll ctrl + space broken +update readme +all exception should be handled in 1-2 chosen levels +everything should throw custom exception classes and log +db wrapper + mark thread as read button, immediately radio + add version label + nested comments, check that only comments content is in viewport + + fix chrome manifest errors + +// ovde na pocetku nove sesije oznacava prethodnu kao procitanu +const { threadId, updatedAt } = existingThread; +const updatedComments = await updateCommentsSessionCreatedAtForThread( + db, + threadId, + updatedAt +); +comment.sessionCreatedAt = thread.updatedAt + +// zapravo meni treba ovo, da oznacim comments mark as rad za current session, sa 2e12 +// a to je addComment() + const sessionCreatedAt = currentSessionCreatedAt; + await addComment(db, { threadId, commentId, sessionCreatedAt }); \ No newline at end of file diff --git a/package.json b/package.json index 2a71cba..56e7eba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "reddit-unread-comments", - "version": "0.0.4", + "version": "1.0.0", "description": "Web extension for easier tracking of new comments on Reddit.", "private": false, "repository": "https://github.com/nemanjam/reddit-unread-comments", @@ -40,8 +40,8 @@ }, "devDependencies": { "@abhijithvijayan/eslint-config": "2.6.3", - "@abhijithvijayan/eslint-config-airbnb": "^1.0.2", - "@abhijithvijayan/tsconfig": "^1.3.0", + "@abhijithvijayan/eslint-config-airbnb": "1.0.2", + "@abhijithvijayan/tsconfig": "1.3.0", "@babel/core": "^7.14.3", "@babel/eslint-parser": "^7.12.16", "@babel/plugin-proposal-class-properties": "^7.13.0", diff --git a/source/Background/service-worker.ts b/source/Background/service-worker.ts new file mode 100644 index 0000000..02d74cd --- /dev/null +++ b/source/Background/service-worker.ts @@ -0,0 +1,7 @@ +import 'emoji-log'; +import browser from 'webextension-polyfill'; + +// for Chrome manifest v3 +browser.runtime.onInstalled.addListener((): void => { + console.emoji('🦄', 'extension installed'); +}); diff --git a/source/manifest-v2-firefox.json b/source/manifest-v2-firefox.json index b0eeee6..1fda161 100644 --- a/source/manifest-v2-firefox.json +++ b/source/manifest-v2-firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Reddit Unread Comments", - "version": "0.0.4", + "version": "1.0.0", "icons": { "16": "assets/icons/favicon-16.png", diff --git a/source/manifest-v3-chrome.json b/source/manifest-v3-chrome.json index c132d51..a46a416 100644 --- a/source/manifest-v3-chrome.json +++ b/source/manifest-v3-chrome.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Reddit Unread Comments", - "version": "0.0.4", + "version": "1.0.0", "icons": { "16": "assets/icons/favicon-16.png", @@ -13,7 +13,9 @@ "homepage_url": "https://github.com/nemanjam/reddit-unread-comments", "short_name": "Reddit Unread Comments", - "permissions": ["activeTab", "http://*.reddit.com/*", "https://*.reddit.com/*"], + "permissions": ["activeTab"], + + "host_permissions": ["http://*.reddit.com/*", "https://*.reddit.com/*"], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" @@ -24,12 +26,6 @@ "name": "Nemanja Mitic" }, - "browser_specific_settings": { - "gecko": { - "id": "{754FB1AD-CC3B-4756-B6A0-7776F7CA9D17}" - } - }, - "__chrome__minimum_chrome_version": "88", "__opera__minimum_opera_version": "36", @@ -53,7 +49,8 @@ }, "background": { - "service_worker": "js/background.bundle.js" + "__firefox__scripts": ["js/background.bundle.js"], + "__chrome|opera|edge__service_worker": "js/bgServiceWorker.bundle.js" }, "content_scripts": [ diff --git a/source/manifest.json b/source/manifest.json index b0eeee6..1fda161 100644 --- a/source/manifest.json +++ b/source/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Reddit Unread Comments", - "version": "0.0.4", + "version": "1.0.0", "icons": { "16": "assets/icons/favicon-16.png", diff --git a/source/reddit-comments/constants/config.ts b/source/reddit-comments/constants/config.ts new file mode 100644 index 0000000..0a452b5 --- /dev/null +++ b/source/reddit-comments/constants/config.ts @@ -0,0 +1,65 @@ +/*----------------------------------- My constants ---------------------------------*/ + +/*---------- url and scroll waiting ---------*/ + +/** Must reduce number of triggers on scroll */ +export const scrollDebounceWait = 1000; + +/** Must wait for routing (change page), and load content */ +export const urlChangeDebounceWait = 2000; + +/*----------- sort by new dropdown ----------*/ + +/** Wait for new comments to reload. */ +export const waitAfterSortByNew = 2000; + +/** Wait for sort menu to load. */ +export const sortMenuWait = 300; + +/*------------------ popup -----------------*/ + +/** Debounce only slider onChange in form. */ +export const formSubmitDebounceWait = 300; + +export const calcHighlightOnTimeDebounceWait = 300; + +/** Refetch every interval. */ +export const highlightedCommentsCountInterval = 1000; + +export const markAllAsReadDbAndDomWait = 500; + +/*--------------- highlighting --------------*/ + +/** In highlight by read mode, delay to mark as read and un-highlight comments. */ +export const markAsReadDelay = 5 * 1000; + +/** Increase comment height by 100px. */ +export const commentHeightHeadroom = 100; + +/*------------- highlight classes ------------*/ + +export const highlightedCommentClass = 'ruc-highlight-comment'; +export const highlightedCommentReadClass = 'ruc-highlight-comment-read'; +/** Both read and unread. Don't use.*/ +export const allHighlightedCommentsSelector = `.${highlightedCommentClass}, .${highlightedCommentReadClass}`; + +export const highlightedCommentByDateClass = 'ruc-highlight-comment-by-date'; + +/*------------------ datetime -----------------*/ + +/** Offset in seconds to fix Date comparison 1hr > 1hr and prevent flicker. */ +export const dateCorrectionOffset = 30 as const; + +/*------------------ database -----------------*/ + +export const databaseName = 'reddit-unread-comments-db'; + +/** 2 * 10**12 */ +export const currentSessionCreatedAt = 2e12 as const; + +/*--------------- database limits --------------*/ + +/** Start deleting at. */ +export const dbSizeLimit: number = 1 * 1024 * 1024; // 1 MB limit +/** Bring to this size. */ +export const dbTargetSize: number = 0.5 * 1024 * 1024; // 0.5 MB target size diff --git a/source/reddit-comments/constants/selectors.ts b/source/reddit-comments/constants/selectors.ts new file mode 100644 index 0000000..5475ab8 --- /dev/null +++ b/source/reddit-comments/constants/selectors.ts @@ -0,0 +1,57 @@ +// selectors, easier to select children than parents + +/*------------------ url regex -----------------*/ + +export const redditThreadUrlRegex = /https?:\/\/www\.?reddit\.com\/r\/\w+\/comments\/.+/; +export const redditUrlRegex = /https?:\/\/www\.?reddit\.com.*/; + +/*------------------ comment -----------------*/ + +export const commentIdAttribute = 'thingid'; +export const commentSelector = 'shreddit-comment[thingid^="t1_"]'; +export const commentIdRegexValidate = /^t1_[a-z0-9]+$/; + +// comment +export const getCommentSelectorFromId = (commentId: string) => + `shreddit-comment[thingid^="${commentId}"]`; + +// timestamp +export const getTimestampSelectorFromId = (commentId: string) => { + const targetCommentSelector = getCommentSelectorFromId(commentId); + + // select direct child element first to exclude nested comments + const timestampSelector = `${targetCommentSelector} > div[slot="commentMeta"] time[datetime]`; + return timestampSelector; +}; + +// content +export const getContentSelectorFromId = (commentId: string) => { + const targetCommentSelector = getCommentSelectorFromId(commentId); + + const contentSelector = `${targetCommentSelector} > div[slot="comment"]`; // used in styles.scss too + return contentSelector; +}; + +/*----------------- thread ----------------*/ + +export const threadPostSelector = 'shreddit-post[id^="t3_"]'; +export const threadPostIdRegexReplace = /^t3_/; // Only to get url id from element.id +export const threadPostIdRegexValidate = /^t3_[a-z0-9]+$/; + +/*----------------- header ----------------*/ + +export const pageHeaderSelector = 'reddit-header-large'; + +/*------------- sort dropdown ------------*/ + +// shadow host 1 +export const sortMenuShadowHostSelector = 'shreddit-sort-dropdown'; +// inside shadow dom +export const currentlySelectedItemSelector = '#comment-sort-button > span > span'; +export const sortMenuClickSelector = '#comment-sort-button'; + +// dropdown item new +export const sortByNewMenuItemSelector = 'div[slot="dropdown-items"] span.text-14'; + +// for blur, outside of shadow dom, simple selector +export const mainContentForBlurSelector = '#main-content'; diff --git a/source/reddit-comments/database/models/settings.ts b/source/reddit-comments/database/models/settings.ts index c4cbad9..435d809 100644 --- a/source/reddit-comments/database/models/settings.ts +++ b/source/reddit-comments/database/models/settings.ts @@ -19,6 +19,7 @@ export const defaultDbValues: SettingsData = { export const defaultValues: SettingsData = { ...defaultDbValues, resetDb: '', + markAllAsRead: false, } as const; export const addSettings = async ( diff --git a/source/reddit-comments/database/models/thread.ts b/source/reddit-comments/database/models/thread.ts index 7cd6582..91f7f1e 100644 --- a/source/reddit-comments/database/models/thread.ts +++ b/source/reddit-comments/database/models/thread.ts @@ -2,7 +2,7 @@ import { currentSessionCreatedAt } from '../../constants'; import { MyModelNotFoundDBException } from '../../exceptions'; import logger from '../../logger'; import { ThreadData, Thread, CommentData, Comment } from '../schema'; -import { updateComment } from './comment'; +import { addComment, updateComment } from './comment'; export const addThread = async ( db: IDBDatabase, @@ -172,3 +172,33 @@ export const updateCommentsSessionCreatedAtForThread = ( }) .catch((error) => reject(error)); }); + +export const markCommentsAsReadInCurrentSessionForThread = ( + db: IDBDatabase, + threadId: string, + commentIds: string[] +): Promise => + new Promise((resolve, reject) => { + getAllCommentsForThread(db, threadId) + .then((dbComments) => { + const newCommentsToAdd = commentIds.filter( + (commentId) => + !dbComments.find((dbComment) => dbComment.commentId === commentId) + ); + + if (!(newCommentsToAdd.length > 0)) return resolve([]); + + const addPromises = newCommentsToAdd.map((commentId) => + addComment(db, { + commentId, + threadId, + sessionCreatedAt: currentSessionCreatedAt, + }) + ); + + Promise.all(addPromises) + .then((addedComments) => resolve(addedComments)) + .catch((error) => reject(error)); + }) + .catch((error) => reject(error)); + }); diff --git a/source/reddit-comments/database/schema.ts b/source/reddit-comments/database/schema.ts index 12202b4..e69d79c 100644 --- a/source/reddit-comments/database/schema.ts +++ b/source/reddit-comments/database/schema.ts @@ -41,6 +41,7 @@ export interface SettingsData { enableLogger: boolean; /** not persisted in db */ resetDb?: ResetDbType; + markAllAsRead?: boolean; } export type SettingsDataKeys = keyof SettingsData; diff --git a/source/reddit-comments/datetime.ts b/source/reddit-comments/datetime.ts index 06f801b..12747d4 100644 --- a/source/reddit-comments/datetime.ts +++ b/source/reddit-comments/datetime.ts @@ -19,6 +19,7 @@ type TimeUnit = | 'year' | 'years'; +/** Unused. */ export const relativeTimeStringToDate = (relativeTime: string): Date => { const [value, unit] = relativeTime.split(' '); let date: Date; diff --git a/source/reddit-comments/dom.ts b/source/reddit-comments/dom.ts deleted file mode 100644 index bdb7cf2..0000000 --- a/source/reddit-comments/dom.ts +++ /dev/null @@ -1,620 +0,0 @@ -import { - MyCreateModelFailedDBException, - MyElementNotFoundDOMException, -} from './exceptions'; -import { - commentHeightHeadroom, - commentSelector, - currentSessionCreatedAt, - highlightedCommentByDateClass, - highlightedCommentClass, - highlightedCommentReadClass, - markAsReadDelay, - modalHeaderSelector, - modalScrollContainerSelector, - pageHeaderSelector, - sortByNewMenuItemSelector, - sortMenuSelector, - sortMenuSpanTextSelector, - sortMenuWait, - threadPostSelector, - timestampIdModalSuffix, - timestampIdPrefix, - waitAfterSortByNew, -} from './constants'; - -import { openDatabase, ThreadData } from './database/schema'; -import { - addThread, - getThread, - updateThread, - getCommentsForThreadWithoutCurrentSession, - updateCommentsSessionCreatedAtForThread, - getCommentsForThreadForCurrentSession, - getAllCommentsForThread, -} from './database/models/thread'; -import { addComment } from './database/models/comment'; -import { limitIndexedDBSize } from './database/limit-size'; - -import { radioAndSliderToDate, relativeTimeStringToDate } from './datetime'; -import { - validateCommentElementIdOrThrow, - validateThreadElementIdOrThrow, -} from './validation'; -import { delayExecution, isActiveTabAndRedditThread, wait } from './utils'; -import { getSettings } from './database/models/settings'; -import logger from './logger'; - -// CommentTopMeta--Created--t1_k8etzzz from t1_k8etzzz -const getTimestampIdFromCommentId = (commentId: string) => { - const modalSuffix = hasModalScrollContainer() ? timestampIdModalSuffix : ''; //! this failed to detect overlay - const timestampId = timestampIdPrefix + commentId + modalSuffix; - return timestampId; -}; - -const getDateFromCommentId = (commentId: string): Date => { - const timestampId = getTimestampIdFromCommentId(commentId); - const timestampElement = document.querySelector(`#${timestampId}`); - - if (!timestampElement) - throw new MyElementNotFoundDOMException( - `Comment timestamp element with timestampId: ${timestampId} not found.` - ); - - const timeAgo = timestampElement.textContent as string; - - const date = relativeTimeStringToDate(timeAgo); - return date; -}; - -/** Returns true if it wasn't by new already. */ -export const clickSortByNewMenuItem = async (): Promise => { - // check if its new already - const sortMenuSpan = document.querySelector(sortMenuSpanTextSelector); - if (!sortMenuSpan) return false; - - if ((sortMenuSpan.textContent as string).toLowerCase().includes('new')) return false; // new already - - // get menu - const sortMenu = document.querySelector(sortMenuSelector); - if (!sortMenu) return false; - - sortMenu.click(); - await wait(sortMenuWait); - - // get items - const menuItems = document.querySelectorAll(sortByNewMenuItemSelector); - - let sortByNewMenuItem: HTMLElement | null = null; - menuItems.forEach((element) => { - if ((element.textContent as string).toLowerCase().includes('new')) - sortByNewMenuItem = element; - }); - - if (sortByNewMenuItem) { - (sortByNewMenuItem as HTMLElement).click(); - sortMenu.blur(); // remove :focus-visible border - } - - return true; -}; - -export const hasModalScrollContainer = (): boolean => { - const modalScrollContainer = document.querySelector( - modalScrollContainerSelector - ); - return Boolean(modalScrollContainer); -}; - -export const getScrollElement = () => { - // detect modal - const modalScrollContainer = document.querySelector( - modalScrollContainerSelector - ); - const scrollElement = modalScrollContainer ?? document; - return scrollElement; -}; - -/** Throws DOM exceptions. */ -export const getThreadIdFromDom = (): string => { - // handle thread post on page and on modal, modalContainer or document - const scrollElement = getScrollElement(); - const threadElement = scrollElement.querySelector(threadPostSelector); - - if (!threadElement) - throw new MyElementNotFoundDOMException( - 'Thread element not found in DOM by attribute.' - ); - - const threadId = validateThreadElementIdOrThrow(threadElement); - - return threadId; -}; - -// sync, fix for big comments -const isElementInViewport = (element: HTMLElement) => { - const rect = element.getBoundingClientRect(); - const elementHeight = commentHeightHeadroom + (rect.bottom - rect.top); - - const isInViewport = - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && - rect.right <= (window.innerWidth || document.documentElement.clientWidth); - - const isHigherThanViewportAndVisible = - elementHeight > window.innerHeight && - (rect.top >= 0 || - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight)); - - const result = isInViewport || isHigherThanViewportAndVisible; - return result; -}; - -const getFilteredNewerCommentsByDate = ( - commentElements: HTMLElement[], - newerThan: Date -): HTMLElement[] => { - const filteredComments = commentElements.filter((commentElement) => { - const commentId = validateCommentElementIdOrThrow(commentElement); - const commentDate = getDateFromCommentId(commentId); // here it throws - return commentDate.getTime() > newerThan.getTime(); - }); - - return filteredComments; -}; - -const addClass = (element: HTMLElement, className: string): void => { - if (!element.classList.contains(className)) element.classList.add(className); -}; - -const removeClass = (element: HTMLElement, className: string): void => { - if (element.classList.contains(className)) element.classList.remove(className); -}; - -/** Works only with DOM elements, no database. */ -const highlightByDate = ( - commentElements: NodeListOf, - newerThan: Date -): void => { - const commentsArray = Array.from(commentElements); - const filteredComments = getFilteredNewerCommentsByDate(commentsArray, newerThan); - const filteredCommentsIds = filteredComments.map((commentElement) => commentElement.id); - - commentElements.forEach((commentElement) => { - const commentId = validateCommentElementIdOrThrow(commentElement); - - const isCommentNewerThan = filteredCommentsIds.includes(commentId); - - // both highlight and un-highlight always, slider can change - - // highlighting - if (isCommentNewerThan) { - addClass(commentElement, highlightedCommentByDateClass); - } - - // un-highlighting - if (!isCommentNewerThan) { - removeClass(commentElement, highlightedCommentByDateClass); - } - }); -}; - -export const removeHighlightClass = () => { - const highlightedElements = document.querySelectorAll( - `.${highlightedCommentClass}` - ); - highlightedElements.forEach((commentElement) => { - commentElement.classList.remove(highlightedCommentClass); - }); -}; -export const removeHighlightReadClass = () => { - const highlightedReadElements = document.querySelectorAll( - `.${highlightedCommentReadClass}` - ); - highlightedReadElements.forEach((commentElement) => { - commentElement.classList.remove(highlightedCommentReadClass); - }); -}; - -export const removeHighlightByDateClass = () => { - const highlightedElementsByDate = document.querySelectorAll( - `.${highlightedCommentByDateClass}` - ); - highlightedElementsByDate.forEach((commentElement) => { - commentElement.classList.remove(highlightedCommentByDateClass); - }); -}; - -export const calcHighlightedByDateCount = (): number => { - const highlightedElementsByDate = document.querySelectorAll( - `.${highlightedCommentByDateClass}` - ); - return highlightedElementsByDate.length; -}; - -export const calcHighlightedUnreadCount = (): number => { - const highlightedElementsUnread = document.querySelectorAll( - `.${highlightedCommentClass}` - ); - return highlightedElementsUnread.length; -}; - -export const getAllComments = (): NodeListOf => { - const commentElements = document.querySelectorAll(commentSelector); - return commentElements; -}; - -/** Only this one should be used. */ -export const highlightByDateWithSettingsData = async ( - commentElements: NodeListOf -) => { - const db = await openDatabase(); - const settings = await getSettings(db); - const { isHighlightOnTime, timeScale, timeSlider } = settings; - - if (isHighlightOnTime) { - const dateInPast = radioAndSliderToDate({ timeScale, timeSlider }); - highlightByDate(commentElements, dateInPast); - } -}; - -/** - * if session: - * onUrlChange - creates session - * onScroll - doesn't create session - */ -export const highlight = async (commentElements: NodeListOf) => { - const db = await openDatabase(); - const threadIdFromDom = getThreadIdFromDom(); - - const { unHighlightOn, isHighlightUnread } = await getSettings(db); - // highlighting disabled - if (!isHighlightUnread) return; - - // works for both realtime and onUrlChange un-highlight, no need for getAllCommentsForThread() - const readCommentsPreviousSessions = await getCommentsForThreadWithoutCurrentSession( - db, - threadIdFromDom - ); - const readCommentsCurrentSession = await getCommentsForThreadForCurrentSession( - db, - threadIdFromDom - ); - - const readCommentsPreviousSessionsIds = readCommentsPreviousSessions.map( - (comment) => comment.commentId - ); - const readCommentsCurrentSessionIds = readCommentsCurrentSession.map( - (comment) => comment.commentId - ); - - commentElements.forEach(async (commentElement) => { - const commentId = validateCommentElementIdOrThrow(commentElement); - // disjunction between all comments and read comments in db - const isReadCommentPreviousSessions = - readCommentsPreviousSessionsIds.includes(commentId); - const isReadCommentCurrentSession = readCommentsCurrentSessionIds.includes(commentId); - - if (isReadCommentPreviousSessions) return; - - // state must come from db, never from dom - - if (unHighlightOn === 'on-scroll') { - // highlighting - if (!isReadCommentCurrentSession) { - // remove highlight read if exists - removeClass(commentElement, highlightedCommentReadClass); - addClass(commentElement, highlightedCommentClass); - } - - // un-highlighting - if (isReadCommentCurrentSession) { - removeClass(commentElement, highlightedCommentClass); - // highlighting read - addClass(commentElement, highlightedCommentReadClass); - } - } - - if (unHighlightOn === 'on-url-change') { - // highlighting - if (!isReadCommentCurrentSession) { - addClass(commentElement, highlightedCommentClass); - } - - // un-highlighting - // remove unconditionally - removeClass(commentElement, highlightedCommentReadClass); - } - }); -}; - -/** Mutates only database, no live DOM updates. */ -const markAsRead = async (commentElements: NodeListOf): Promise => { - // because of delay - if (!isActiveTabAndRedditThread()) return; - - const db = await openDatabase(); - - const { isHighlightUnread } = await getSettings(db); - // marking disabled - if (!isHighlightUnread) return; - - const thread = await getCurrentThread(); - const { threadId } = thread; - - // all read comments from all sessions - // updating comments only onUrlChange, thread load - const allSessionsComments = await getAllCommentsForThread(db, threadId); - const allSessionsCommentsIds = allSessionsComments.map((comment) => comment.commentId); - - // unfiltered comments here, for entire session, only new are fine? - const latestCommentUpdater = createLatestCommentUpdater(commentElements[0]); - - commentElements.forEach(async (commentElement) => { - const commentId = validateCommentElementIdOrThrow(commentElement); - const isAlreadyMarkedComment = allSessionsCommentsIds.includes(commentId); // all checks in one loop - - if (!isElementInViewport(commentElement) || isAlreadyMarkedComment) return; - - const sessionCreatedAt = currentSessionCreatedAt; - await addComment(db, { threadId, commentId, sessionCreatedAt }); - - // update sorted comments - latestCommentUpdater.updateLatestComment(commentElement); - }); - - const { latestCommentId, latestCommentDate } = latestCommentUpdater.getLatestComment(); - - // update thread bellow forEach - await updateThread(db, { - threadId, - ...(latestCommentId && { latestCommentId }), - ...(latestCommentDate && { latestCommentTimestamp: latestCommentDate.getTime() }), - }); -}; - -/** Used only for max elem for Thread.latestCommentId in db. */ -const createLatestCommentUpdater = (initialCommentElement: HTMLElement) => { - const initialCommentId = validateCommentElementIdOrThrow(initialCommentElement); - - let latestCommentId = initialCommentId; - let latestCommentDate = getDateFromCommentId(initialCommentId); - - const updateLatestComment = (commentElement: HTMLElement) => { - const commentId = validateCommentElementIdOrThrow(commentElement); - const currentDate = getDateFromCommentId(commentId); - - if (currentDate > latestCommentDate) { - latestCommentId = commentId; - latestCommentDate = currentDate; - } - }; - - return { - updateLatestComment, - getLatestComment: () => ({ latestCommentId, latestCommentDate }), - }; -}; - -const getCurrentThread = async (): Promise => { - const threadIdFromDom = getThreadIdFromDom(); - - const db = await openDatabase(); - const thread = await getThread(db, threadIdFromDom); // will throw - - return thread; -}; - -export const updateCommentsFromPreviousSessionOrCreateThread = - async (): Promise => { - let result = {}; - - const threadIdFromDom = getThreadIdFromDom(); - - const db = await openDatabase(); - const existingThread = await getThread(db, threadIdFromDom).catch((_error) => - logger.info(`First run, thread with threadIdFromDom:${threadIdFromDom} not found.`) - ); - - if (existingThread) { - const { threadId, updatedAt } = existingThread; - const updatedComments = await updateCommentsSessionCreatedAtForThread( - db, - threadId, - updatedAt - ); - - const message = - updatedComments.length > 0 - ? `Updated ${updatedComments.length} pending comments from previous session.` - : 'No pending comments to update from previous session.'; - logger.info(message); - - result = { - isExistingThread: true, - thread: existingThread, - updatedComments, - }; - } else { - // new thread detected - - // reduce db size here, before adding new thread - await limitIndexedDBSize(db); - - // add new thread if it doesn't exist - const newThread = await addThread(db, { - threadId: threadIdFromDom, - updatedAt: new Date().getTime(), // first run creates session - comment.currentCreatedAt - }); - - if (!newThread) - throw new MyCreateModelFailedDBException('Failed to create new Thread.'); - - result = { - isExistingThread: false, - thread: newThread, - updatedComments: [], - }; - } - - logger.info('updateCommentsFromPreviousSessionOrCreateThread debug result:', result); - }; - -export const getHeaderHeight = () => { - if (hasModalScrollContainer()) { - const modalHeaderElement = document.querySelector(modalHeaderSelector); - if (!modalHeaderElement) - throw new MyElementNotFoundDOMException('Modal header element not found.'); - - const headerHeight = modalHeaderElement.getBoundingClientRect().height; - return headerHeight; - } - - const headerElement = document.querySelector(pageHeaderSelector); - if (!headerElement) - throw new MyElementNotFoundDOMException('Header element not found.'); - - const headerHeight = headerElement.getBoundingClientRect().height; - return headerHeight; -}; - -const createCurrentIndexUpdater = () => { - // state - let currentIndex = 0; - - const getCurrentIndex = () => currentIndex; - - const setCurrentIndex = (value: number) => { - currentIndex = value; - }; - - return { - getCurrentIndex, - setCurrentIndex, - }; -}; - -/** Use only this instance. */ -export const currentIndex = createCurrentIndexUpdater(); - -export const scrollNextCommentIntoView = async (scrollToFirstComment = false) => { - const db = await openDatabase(); - const settingsData = await getSettings(db); - - const highlightedCommentsSelector = `.${highlightedCommentClass}`; - const highlightedCommentsByDateSelector = `.${highlightedCommentByDateClass}`; - - const commentsSelectorMap = { - unread: highlightedCommentsSelector, - 'by-date': highlightedCommentsByDateSelector, - both: `${highlightedCommentsSelector}, ${highlightedCommentsByDateSelector}`, - }; - const chosenCommentsSelector = commentsSelectorMap[settingsData.scrollTo]; - - const commentElements = document.querySelectorAll(chosenCommentsSelector); - - if (!(commentElements.length > 0)) return; - - if (scrollToFirstComment) { - // scroll to first highlighted comment - currentIndex.setCurrentIndex(0); - } else { - // find currentIndex for first element that is not in viewport - for ( - let index = currentIndex.getCurrentIndex(); - index < commentElements.length; - index++ - ) { - if (!isElementInViewport(commentElements[index])) { - currentIndex.setCurrentIndex(index); - break; - } - - // last iteration - if (index > commentElements.length - 2) { - currentIndex.setCurrentIndex(0); - } - } - } - - const commentElement = commentElements[currentIndex.getCurrentIndex()]; - - const commentRect = commentElement.getBoundingClientRect(); - - const modalScrollContainer = document.querySelector( - modalScrollContainerSelector - ); - - const headerHeight = getHeaderHeight(); - - if (modalScrollContainer) { - const commentOffsetTop = commentElement.getBoundingClientRect().top; - const modalOffsetTop = modalScrollContainer.getBoundingClientRect().top; - - const targetScrollTop = - modalScrollContainer.scrollTop + commentOffsetTop - modalOffsetTop - headerHeight; - - modalScrollContainer.scrollTo({ - top: targetScrollTop, - behavior: 'smooth', - }); - } else { - window.scrollTo({ - top: commentRect.top + window.scrollY - headerHeight, - behavior: 'smooth', - }); - } -}; - -/** onScroll - markAsRead, highlight */ -export const handleScrollDom = async () => { - // disable handlers too, and not attaching only - if (!isActiveTabAndRedditThread()) return; - - const commentElements = document.querySelectorAll(commentSelector); - if (!(commentElements.length > 0)) return; - - try { - // independent of comments in database, comes first - //! scroll fires with scrollDebounceWait = 1000 before urlChange 2 seconds, and overlayId is not found, try to fix - await highlightByDateWithSettingsData(commentElements); - - await delayExecution(markAsRead, markAsReadDelay, commentElements); // always delay - // check after delay again - if (!isActiveTabAndRedditThread()) return; - - await highlight(commentElements); - } catch (error) { - logger.error('Error handling comments onScroll:', error); - } -}; - -/** updateCommentsFromPreviousSession, highlight */ -export const handleUrlChangeDom = async () => { - if (!isActiveTabAndRedditThread()) return; - - try { - const db = await openDatabase(); - const { sortAllByNew } = await getSettings(db); - if (sortAllByNew) { - const hasSorted = await clickSortByNewMenuItem(); - if (hasSorted) { - // delay must be AFTER sort - await wait(waitAfterSortByNew); - } - } - //! important, must select element AFTER sort - const commentElements = document.querySelectorAll(commentSelector); - // only root check, child functions must have commentElements array filled - if (!(commentElements.length > 0)) return; - - await updateCommentsFromPreviousSessionOrCreateThread(); - await highlight(commentElements); - - // completely independent from db highlighting, can run in parallel - await highlightByDateWithSettingsData(commentElements); - } catch (error) { - logger.error('Error handling comments onUrlChange:', error); - } -}; diff --git a/source/reddit-comments/dom/handle-scroll.ts b/source/reddit-comments/dom/handle-scroll.ts new file mode 100644 index 0000000..b09f1da --- /dev/null +++ b/source/reddit-comments/dom/handle-scroll.ts @@ -0,0 +1,29 @@ +import { markAsReadDelay } from '../constants/config'; +import { commentSelector } from '../constants/selectors'; +import { highlightByDateWithSettingsData } from '../dom/highlight-by-date'; +import { highlightByRead, markAsRead } from '../dom/highlight-by-read'; +import { delayExecution, isActiveTabAndRedditThread } from '../utils'; +import logger from '../logger'; + +/** onScroll - markAsRead, highlight */ +export const handleScrollDom = async () => { + // disable handlers too, and not attaching only + if (!isActiveTabAndRedditThread()) return; + + const commentElements = document.querySelectorAll(commentSelector); + if (!(commentElements.length > 0)) return; + + try { + // independent of comments in database, comes first + //! scroll fires with scrollDebounceWait = 1000 before urlChange 2 seconds, and overlayId is not found, try to fix + await highlightByDateWithSettingsData(commentElements); + + await delayExecution(markAsRead, markAsReadDelay, commentElements); // always delay + // check after delay again + if (!isActiveTabAndRedditThread()) return; + + await highlightByRead(commentElements); + } catch (error) { + logger.error('Error handling comments onScroll:', error); + } +}; diff --git a/source/reddit-comments/dom/handle-url-change.ts b/source/reddit-comments/dom/handle-url-change.ts new file mode 100644 index 0000000..5aa0466 --- /dev/null +++ b/source/reddit-comments/dom/handle-url-change.ts @@ -0,0 +1,41 @@ +import { markAsReadDelay, waitAfterSortByNew } from '../constants/config'; +import { commentSelector } from '../constants/selectors'; +import { getSettings } from '../database/models/settings'; +import { openDatabase } from '../database/schema'; +import { highlightByDateWithSettingsData } from '../dom/highlight-by-date'; +import { + highlightByRead, + updateCommentsFromPreviousSessionOrCreateThread, +} from '../dom/highlight-by-read'; +import { clickSortByNewMenuItem } from '../dom/sort-by-new'; +import { isActiveTabAndRedditThread, wait } from '../utils'; +import logger from '../logger'; + +/** updateCommentsFromPreviousSession, highlight */ +export const handleUrlChangeDom = async () => { + if (!isActiveTabAndRedditThread()) return; + + try { + const db = await openDatabase(); + const { sortAllByNew } = await getSettings(db); + if (sortAllByNew) { + const hasSorted = await clickSortByNewMenuItem(); + if (hasSorted) { + // delay must be AFTER sort + await wait(waitAfterSortByNew); + } + } + //! important, must select element AFTER sort + const commentElements = document.querySelectorAll(commentSelector); + // only root check, child functions must have commentElements array filled + if (!(commentElements.length > 0)) return; + + await updateCommentsFromPreviousSessionOrCreateThread(); + await highlightByRead(commentElements); + + // completely independent from db highlighting, can run in parallel + await highlightByDateWithSettingsData(commentElements); + } catch (error) { + logger.error('Error handling comments onUrlChange:', error); + } +}; diff --git a/source/reddit-comments/dom/highlight-by-date.ts b/source/reddit-comments/dom/highlight-by-date.ts new file mode 100644 index 0000000..632087b --- /dev/null +++ b/source/reddit-comments/dom/highlight-by-date.ts @@ -0,0 +1,67 @@ +import { highlightedCommentByDateClass } from '../constants/config'; +import { commentIdAttribute } from '../constants/selectors'; +import { getSettings } from '../database/models/settings'; +import { openDatabase } from '../database/schema'; +import { radioAndSliderToDate } from '../datetime'; +import { validateCommentElementIdOrThrow } from '../validation'; +import { addClass, removeClass } from './highlight-common'; +import { getDateFromCommentId } from './timestamp'; + +export const getFilteredNewerCommentsByDate = ( + commentElements: HTMLElement[], + newerThan: Date +): HTMLElement[] => { + const filteredComments = commentElements.filter((commentElement) => { + const commentId = validateCommentElementIdOrThrow(commentElement); + const commentDate = getDateFromCommentId(commentId); // here it throws + + const isNewComment = commentDate.getTime() > newerThan.getTime(); + return isNewComment; + }); + + return filteredComments; +}; + +/** Works only with DOM elements, no database. */ +export const highlightByDate = ( + commentElements: NodeListOf, + newerThan: Date +): void => { + const commentsArray = Array.from(commentElements); + const filteredComments = getFilteredNewerCommentsByDate(commentsArray, newerThan); + const filteredCommentsIds = filteredComments.map((commentElement) => + validateCommentElementIdOrThrow(commentElement) + ); + + commentElements.forEach((commentElement) => { + const commentId = validateCommentElementIdOrThrow(commentElement); + + const isCommentNewerThan = filteredCommentsIds.includes(commentId); + + // both highlight and un-highlight always, slider can change + + // highlighting + if (isCommentNewerThan) { + addClass(commentElement, highlightedCommentByDateClass); + } + + // un-highlighting + if (!isCommentNewerThan) { + removeClass(commentElement, highlightedCommentByDateClass); + } + }); +}; + +/** Only this one should be used. */ +export const highlightByDateWithSettingsData = async ( + commentElements: NodeListOf +) => { + const db = await openDatabase(); + const settings = await getSettings(db); + const { isHighlightOnTime, timeScale, timeSlider } = settings; + + if (isHighlightOnTime) { + const dateInPast = radioAndSliderToDate({ timeScale, timeSlider }); + highlightByDate(commentElements, dateInPast); + } +}; diff --git a/source/reddit-comments/dom/highlight-by-read.ts b/source/reddit-comments/dom/highlight-by-read.ts new file mode 100644 index 0000000..bdbf38d --- /dev/null +++ b/source/reddit-comments/dom/highlight-by-read.ts @@ -0,0 +1,240 @@ +import { + currentSessionCreatedAt, + highlightedCommentClass, + highlightedCommentReadClass, +} from '../constants/config'; +import { limitIndexedDBSize } from '../database/limit-size'; +import { addComment } from '../database/models/comment'; +import { getSettings } from '../database/models/settings'; +import { + addThread, + getAllCommentsForThread, + getCommentsForThreadForCurrentSession, + getCommentsForThreadWithoutCurrentSession, + getThread, + updateCommentsSessionCreatedAtForThread, + updateThread, +} from '../database/models/thread'; +import { openDatabase, ThreadData } from '../database/schema'; +import { MyCreateModelFailedDBException } from '../exceptions'; +import logger from '../logger'; +import { isActiveTabAndRedditThread } from '../utils'; +import { validateCommentElementIdOrThrow } from '../validation'; +import { + addClass, + getCommentContentElement, + isElementInViewport, + removeClass, +} from './highlight-common'; +import { getThreadIdFromDom } from './thread'; +import { getDateFromCommentId } from './timestamp'; + +/** + * if session: + * onUrlChange - creates session + * onScroll - doesn't create session + */ +export const highlightByRead = async (commentElements: NodeListOf) => { + const db = await openDatabase(); + const threadIdFromDom = getThreadIdFromDom(); + + const { unHighlightOn, isHighlightUnread } = await getSettings(db); + // highlighting disabled + if (!isHighlightUnread) return; + + // works for both realtime and onUrlChange un-highlight, no need for getAllCommentsForThread() + const readCommentsPreviousSessions = await getCommentsForThreadWithoutCurrentSession( + db, + threadIdFromDom + ); + const readCommentsCurrentSession = await getCommentsForThreadForCurrentSession( + db, + threadIdFromDom + ); + + const readCommentsPreviousSessionsIds = readCommentsPreviousSessions.map( + (comment) => comment.commentId + ); + const readCommentsCurrentSessionIds = readCommentsCurrentSession.map( + (comment) => comment.commentId + ); + + commentElements.forEach(async (commentElement) => { + const commentId = validateCommentElementIdOrThrow(commentElement); + // disjunction between all comments and read comments in db + const isReadCommentPreviousSessions = + readCommentsPreviousSessionsIds.includes(commentId); + const isReadCommentCurrentSession = readCommentsCurrentSessionIds.includes(commentId); + + if (isReadCommentPreviousSessions) return; + + // state must come from db, never from dom + + if (unHighlightOn === 'on-scroll') { + // highlighting + if (!isReadCommentCurrentSession) { + // remove highlight read if exists + removeClass(commentElement, highlightedCommentReadClass); + addClass(commentElement, highlightedCommentClass); + } + + // un-highlighting + if (isReadCommentCurrentSession) { + removeClass(commentElement, highlightedCommentClass); + // highlighting read + addClass(commentElement, highlightedCommentReadClass); + } + } + + if (unHighlightOn === 'on-url-change') { + // highlighting + if (!isReadCommentCurrentSession) { + addClass(commentElement, highlightedCommentClass); + } + + // un-highlighting + // remove unconditionally + removeClass(commentElement, highlightedCommentReadClass); + } + }); +}; + +/** Used only for max elem for Thread.latestCommentId in db. */ +const createLatestCommentUpdater = (initialCommentElement: HTMLElement) => { + const initialCommentId = validateCommentElementIdOrThrow(initialCommentElement); + + let latestCommentId = initialCommentId; + let latestCommentDate = getDateFromCommentId(initialCommentId); + + const updateLatestComment = (commentElement: HTMLElement) => { + const commentId = validateCommentElementIdOrThrow(commentElement); + const currentDate = getDateFromCommentId(commentId); + + if (currentDate > latestCommentDate) { + latestCommentId = commentId; + latestCommentDate = currentDate; + } + }; + + return { + updateLatestComment, + getLatestComment: () => ({ latestCommentId, latestCommentDate }), + }; +}; + +const getCurrentThread = async (): Promise => { + const threadIdFromDom = getThreadIdFromDom(); + + const db = await openDatabase(); + const thread = await getThread(db, threadIdFromDom); // will throw + + return thread; +}; + +/** Mutates only database, no live DOM updates. */ +export const markAsRead = async ( + commentElements: NodeListOf +): Promise => { + // because of delay + if (!isActiveTabAndRedditThread()) return; + + const db = await openDatabase(); + + const { isHighlightUnread } = await getSettings(db); + // marking disabled + if (!isHighlightUnread) return; + + const thread = await getCurrentThread(); + const { threadId } = thread; + + // all read comments from all sessions + // updating comments only onUrlChange, thread load + const allSessionsComments = await getAllCommentsForThread(db, threadId); + const allSessionsCommentsIds = allSessionsComments.map((comment) => comment.commentId); + + // unfiltered comments here, for entire session, only new are fine? + const latestCommentUpdater = createLatestCommentUpdater(commentElements[0]); + + commentElements.forEach(async (commentElement) => { + const commentId = validateCommentElementIdOrThrow(commentElement); + const isAlreadyMarkedComment = allSessionsCommentsIds.includes(commentId); // all checks in one loop + + // since comments are now nested, select only content of the comment + const commentContentElement = getCommentContentElement(commentElement); + if (!commentContentElement) return; + + if (!isElementInViewport(commentContentElement) || isAlreadyMarkedComment) return; + + const sessionCreatedAt = currentSessionCreatedAt; + await addComment(db, { threadId, commentId, sessionCreatedAt }); + + // update sorted comments + latestCommentUpdater.updateLatestComment(commentElement); + }); + + const { latestCommentId, latestCommentDate } = latestCommentUpdater.getLatestComment(); + + // update thread bellow forEach + await updateThread(db, { + threadId, + ...(latestCommentId && { latestCommentId }), + ...(latestCommentDate && { + latestCommentTimestamp: latestCommentDate.getTime(), + }), + }); +}; + +export const updateCommentsFromPreviousSessionOrCreateThread = + async (): Promise => { + let result = {}; + + const threadIdFromDom = getThreadIdFromDom(); + + const db = await openDatabase(); + const existingThread = await getThread(db, threadIdFromDom).catch((_error) => + logger.info(`First run, thread with threadIdFromDom:${threadIdFromDom} not found.`) + ); + + if (existingThread) { + const { threadId, updatedAt } = existingThread; + const updatedComments = await updateCommentsSessionCreatedAtForThread( + db, + threadId, + updatedAt + ); + + const message = + updatedComments.length > 0 + ? `Updated ${updatedComments.length} pending comments from previous session.` + : 'No pending comments to update from previous session.'; + logger.info(message); + + result = { + isExistingThread: true, + thread: existingThread, + updatedComments, + }; + } else { + // new thread detected + + // reduce db size here, before adding new thread + await limitIndexedDBSize(db); + + // add new thread if it doesn't exist + const newThread = await addThread(db, { + threadId: threadIdFromDom, + updatedAt: new Date().getTime(), // first run creates session - comment.currentCreatedAt + }); + + if (!newThread) + throw new MyCreateModelFailedDBException('Failed to create new Thread.'); + + result = { + isExistingThread: false, + thread: newThread, + updatedComments: [], + }; + } + + logger.info('updateCommentsFromPreviousSessionOrCreateThread debug result:', result); + }; diff --git a/source/reddit-comments/dom/highlight-common.ts b/source/reddit-comments/dom/highlight-common.ts new file mode 100644 index 0000000..bd51e23 --- /dev/null +++ b/source/reddit-comments/dom/highlight-common.ts @@ -0,0 +1,100 @@ +import { + commentHeightHeadroom, + highlightedCommentByDateClass, + highlightedCommentClass, + highlightedCommentReadClass, +} from '../constants/config'; +import { commentSelector, getContentSelectorFromId } from '../constants/selectors'; +import { validateCommentElementIdOrThrow } from '../validation'; + +// sync, fix for big comments +export const isElementInViewport = (element: HTMLElement): boolean => { + const rect = element.getBoundingClientRect(); + const elementHeight = commentHeightHeadroom + (rect.bottom - rect.top); + + const isInViewport = + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth); + + const isHigherThanViewportAndVisible = + elementHeight > window.innerHeight && + (rect.top >= 0 || + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight)); + + const result = isInViewport || isHigherThanViewportAndVisible; + return result; +}; + +export const getCommentContentElement = (commentElement: HTMLElement) => { + const commentId = validateCommentElementIdOrThrow(commentElement); + const contentSelector = getContentSelectorFromId(commentId); + + // query from commentElement to save performance + // selector for current element can match element itself + const contentElement = commentElement.querySelector(contentSelector); + return contentElement; +}; + +export const addClass = (element: HTMLElement, className: string): void => { + if (!element.classList.contains(className)) element.classList.add(className); +}; + +export const removeClass = (element: HTMLElement, className: string): void => { + if (element.classList.contains(className)) element.classList.remove(className); +}; + +export const removeHighlightClass = () => { + const highlightedElements = document.querySelectorAll( + `.${highlightedCommentClass}` + ); + highlightedElements.forEach((commentElement) => { + commentElement.classList.remove(highlightedCommentClass); + }); +}; + +export const removeHighlightReadClass = () => { + const highlightedReadElements = document.querySelectorAll( + `.${highlightedCommentReadClass}` + ); + highlightedReadElements.forEach((commentElement) => { + commentElement.classList.remove(highlightedCommentReadClass); + }); +}; + +export const removeHighlightByDateClass = () => { + const highlightedElementsByDate = document.querySelectorAll( + `.${highlightedCommentByDateClass}` + ); + highlightedElementsByDate.forEach((commentElement) => { + commentElement.classList.remove(highlightedCommentByDateClass); + }); +}; + +export const calcHighlightedByDateCount = (): number => { + const highlightedElementsByDate = document.querySelectorAll( + `.${highlightedCommentByDateClass}` + ); + return highlightedElementsByDate.length; +}; + +export const calcHighlightedUnreadCount = (): number => { + const highlightedElementsUnread = document.querySelectorAll( + `.${highlightedCommentClass}` + ); + return highlightedElementsUnread.length; +}; + +export const getAllComments = (): NodeListOf => { + const commentElements = document.querySelectorAll(commentSelector); + return commentElements; +}; + +export const getAllCommentsIds = (): string[] => { + const commentElements = getAllComments(); + const commentIds = Array.from(commentElements).map((commentElement) => + validateCommentElementIdOrThrow(commentElement) + ); + return commentIds; +}; diff --git a/source/reddit-comments/dom/scroll-to-comment.ts b/source/reddit-comments/dom/scroll-to-comment.ts new file mode 100644 index 0000000..d590050 --- /dev/null +++ b/source/reddit-comments/dom/scroll-to-comment.ts @@ -0,0 +1,87 @@ +import { + highlightedCommentByDateClass, + highlightedCommentClass, +} from '../constants/config'; +import { pageHeaderSelector } from '../constants/selectors'; +import { getSettings } from '../database/models/settings'; +import { openDatabase } from '../database/schema'; +import { MyElementNotFoundDOMException } from '../exceptions'; +import { isElementInViewport } from './highlight-common'; + +export const getHeaderHeight = () => { + const headerElement = document.querySelector(pageHeaderSelector); + if (!headerElement) + throw new MyElementNotFoundDOMException('Header element not found.'); + + const headerHeight = headerElement.getBoundingClientRect().height; + return headerHeight; +}; + +const createCurrentIndexUpdater = () => { + // state + let currentIndex = 0; + + const getCurrentIndex = () => currentIndex; + + const setCurrentIndex = (value: number) => { + currentIndex = value; + }; + + return { + getCurrentIndex, + setCurrentIndex, + }; +}; + +/** Use only this instance. */ +export const currentIndex = createCurrentIndexUpdater(); + +export const scrollNextCommentIntoView = async (scrollToFirstComment = false) => { + const db = await openDatabase(); + const settingsData = await getSettings(db); + + const highlightedCommentsSelector = `.${highlightedCommentClass}`; + const highlightedCommentsByDateSelector = `.${highlightedCommentByDateClass}`; + + const commentsSelectorMap = { + unread: highlightedCommentsSelector, + 'by-date': highlightedCommentsByDateSelector, + both: `${highlightedCommentsSelector}, ${highlightedCommentsByDateSelector}`, + }; + const chosenCommentsSelector = commentsSelectorMap[settingsData.scrollTo]; + + const commentElements = document.querySelectorAll(chosenCommentsSelector); + + if (!(commentElements.length > 0)) return; + + if (scrollToFirstComment) { + // scroll to first highlighted comment + currentIndex.setCurrentIndex(0); + } else { + // find currentIndex for first element that is not in viewport + for ( + let index = currentIndex.getCurrentIndex(); + index < commentElements.length; + index++ + ) { + if (!isElementInViewport(commentElements[index])) { + currentIndex.setCurrentIndex(index); + break; + } + + // last iteration + if (index > commentElements.length - 2) { + currentIndex.setCurrentIndex(0); + } + } + } + + const commentElement = commentElements[currentIndex.getCurrentIndex()]; + const commentRect = commentElement.getBoundingClientRect(); + const headerHeight = getHeaderHeight(); + + window.scrollTo({ + top: commentRect.top + window.scrollY - headerHeight, + behavior: 'smooth', + }); +}; diff --git a/source/reddit-comments/dom/sort-by-new.ts b/source/reddit-comments/dom/sort-by-new.ts new file mode 100644 index 0000000..ed6fa48 --- /dev/null +++ b/source/reddit-comments/dom/sort-by-new.ts @@ -0,0 +1,73 @@ +import { sortMenuWait } from '../constants/config'; +import { + currentlySelectedItemSelector, + sortByNewMenuItemSelector, + sortMenuShadowHostSelector, + sortMenuClickSelector, + mainContentForBlurSelector, +} from '../constants/selectors'; +import { MyElementNotFoundDOMException } from '../exceptions'; +import { wait } from '../utils'; + +/** Returns true if it wasn't by new already. */ +export const clickSortByNewMenuItem = async (): Promise => { + // get currently selected sort order + + // same shadowHost is reused for currently selected item and dropdown menu click + const shadowHost = document.querySelector(sortMenuShadowHostSelector); + const shadowRoot = shadowHost?.shadowRoot; + if (!shadowRoot) + throw new MyElementNotFoundDOMException( + `shadowRoot not found. sortMenuShadowHostSelector: ${sortMenuShadowHostSelector}` + ); + + const currentlySelectedElement = shadowRoot.querySelector( + currentlySelectedItemSelector + ); + const currentlySelectedElementText = currentlySelectedElement?.textContent; + if (!currentlySelectedElementText) + throw new MyElementNotFoundDOMException( + `currentlySelectedElementText not found. currentlySelectedElementText: ${currentlySelectedElementText}` + ); + + const currentlySelectedText = currentlySelectedElementText.toLowerCase(); + if (currentlySelectedText.includes('new')) return false; // new already + + // get dropdown menu + const sortMenu = shadowRoot.querySelector(sortMenuClickSelector); + if (!sortMenu) + throw new MyElementNotFoundDOMException( + `sortMenu not found. sortMenuClickSelector: ${sortMenuClickSelector}` + ); + sortMenu.click(); + + await wait(sortMenuWait); + + // get items + const menuItems = document.querySelectorAll(sortByNewMenuItemSelector); + + if (!(menuItems?.length > 0)) + throw new MyElementNotFoundDOMException( + `menuItems not found. sortByNewMenuItemSelector: ${sortByNewMenuItemSelector}` + ); + + let sortByNewMenuItem: HTMLElement | null = null; + menuItems.forEach((element) => { + const itemText = element.textContent; + if (itemText?.toLowerCase().includes('new')) sortByNewMenuItem = element; + }); + + if (sortByNewMenuItem) { + (sortByNewMenuItem as HTMLElement).click(); + + // remove :focus-visible border + const mainContent = document.querySelector(mainContentForBlurSelector); + if (!mainContent) + throw new MyElementNotFoundDOMException( + `mainContent not found. mainContentForBlurSelector: ${mainContentForBlurSelector}` + ); + mainContent?.click(); + } + + return true; +}; diff --git a/source/reddit-comments/dom/thread.ts b/source/reddit-comments/dom/thread.ts new file mode 100644 index 0000000..d3cfa36 --- /dev/null +++ b/source/reddit-comments/dom/thread.ts @@ -0,0 +1,17 @@ +import { threadPostSelector } from '../constants/selectors'; +import { MyElementNotFoundDOMException } from '../exceptions'; +import { validateThreadElementIdOrThrow } from '../validation'; + +/** Throws DOM exceptions. */ +export const getThreadIdFromDom = (): string => { + const threadElement = document.querySelector(threadPostSelector); + + if (!threadElement) + throw new MyElementNotFoundDOMException( + 'Thread element not found in DOM by attribute.' + ); + + const threadId = validateThreadElementIdOrThrow(threadElement); + + return threadId; +}; diff --git a/source/reddit-comments/dom/timestamp.ts b/source/reddit-comments/dom/timestamp.ts new file mode 100644 index 0000000..1d3a24a --- /dev/null +++ b/source/reddit-comments/dom/timestamp.ts @@ -0,0 +1,31 @@ +import { getTimestampSelectorFromId } from '../constants/selectors'; +import { + MyElementAttributeNotValidDOMException, + MyElementNotFoundDOMException, +} from '../exceptions'; + +export const getTimestampElementFromCommentId = (commentId: string) => { + const timestampSelector = getTimestampSelectorFromId(commentId); + const timestampElement = document.querySelector(timestampSelector); + + return timestampElement; +}; + +export const getDateFromCommentId = (commentId: string): Date => { + const timestampElement = getTimestampElementFromCommentId(commentId); + + if (!timestampElement) + throw new MyElementNotFoundDOMException( + `Comment timestamp element with commentId: ${commentId} not found.` + ); + + const dateIsoString = timestampElement.getAttribute('datetime'); + + if (!dateIsoString) + throw new MyElementAttributeNotValidDOMException( + `getDateFromCommentId dateIsoString: ${dateIsoString}, date.datetime attribute not defined.` + ); + + const date = new Date(dateIsoString); + return date; +}; diff --git a/source/reddit-comments/events/index.ts b/source/reddit-comments/events/index.ts new file mode 100644 index 0000000..62a8a10 --- /dev/null +++ b/source/reddit-comments/events/index.ts @@ -0,0 +1,24 @@ +import { truncateDatabase } from '../database/limit-size'; +import { initSettings } from '../database/models/settings'; +import { openDatabase } from '../database/schema'; +import { isActiveTab } from '../utils'; +import { onReceiveMessage } from './on-message'; +import { onUrlChange } from './on-url-change'; + +/*-------------------------------- Entry point ------------------------------*/ + +export const attachAllEventHandlers = async () => { + if (!isActiveTab()) return; + + // await truncateDatabase(); + + // create database + const db = await openDatabase(); + await initSettings(db); + + onReceiveMessage(); + onUrlChange(); +}; + +// rerun everything when tab gets focus +document.addEventListener('visibilitychange', attachAllEventHandlers); diff --git a/source/reddit-comments/events/on-key-down.ts b/source/reddit-comments/events/on-key-down.ts new file mode 100644 index 0000000..c484187 --- /dev/null +++ b/source/reddit-comments/events/on-key-down.ts @@ -0,0 +1,14 @@ +import { scrollNextCommentIntoView } from '../dom/scroll-to-comment'; + +/*------------------------------- onKeyDown -----------------------------*/ + +export const handleCtrlSpaceKeyDown = async (event: KeyboardEvent) => { + // ctrl + shift + space -> scroll to first + if (event.ctrlKey && event.code === 'Space') { + if (event.shiftKey) { + await scrollNextCommentIntoView(true); + } else { + await scrollNextCommentIntoView(); + } + } +}; diff --git a/source/reddit-comments/events.ts b/source/reddit-comments/events/on-message.ts similarity index 61% rename from source/reddit-comments/events.ts rename to source/reddit-comments/events/on-message.ts index 4595d79..eafa74a 100644 --- a/source/reddit-comments/events.ts +++ b/source/reddit-comments/events/on-message.ts @@ -1,122 +1,43 @@ -import browser, { Runtime } from 'webextension-polyfill'; +/*---------------------- Listen to messages in contentScript --------------------*/ +import browser, { Runtime } from 'webextension-polyfill'; +import { waitAfterSortByNew } from '../constants/config'; +import { + deleteAllThreadsWithComments, + deleteThreadWithComments, +} from '../database/limit-size'; +import { + defaultValues, + getSettings, + resetSettings, + updateSettings, +} from '../database/models/settings'; +import { markCommentsAsReadInCurrentSessionForThread } from '../database/models/thread'; +import { openDatabase, SettingsData, SettingsDataKeys } from '../database/schema'; +import { highlightByDateWithSettingsData } from '../dom/highlight-by-date'; import { - debounce, - detectChanges, - hasArrivedToRedditThread, - hasLeftRedditThread, - isActiveTab, - wait, -} from './utils'; + highlightByRead, + updateCommentsFromPreviousSessionOrCreateThread, +} from '../dom/highlight-by-read'; import { calcHighlightedByDateCount, calcHighlightedUnreadCount, - clickSortByNewMenuItem, - currentIndex, getAllComments, - getScrollElement, - getThreadIdFromDom, - handleScrollDom, - handleUrlChangeDom, - highlight, - highlightByDateWithSettingsData, + getAllCommentsIds, removeHighlightByDateClass, removeHighlightClass, removeHighlightReadClass, - scrollNextCommentIntoView, - updateCommentsFromPreviousSessionOrCreateThread, -} from './dom'; -import { - scrollDebounceWait, - urlChangeDebounceWait, - waitAfterSortByNew, -} from './constants'; -import { - deleteAllThreadsWithComments, - deleteThreadWithComments, - getAllDbData, - truncateDatabase, -} from './database/limit-size'; -import { messageTypes, MyMessageType } from './message'; -import { openDatabase, SettingsData, SettingsDataKeys } from './database/schema'; -import { - getSettings, - initSettings, - resetSettings, - updateSettings, -} from './database/models/settings'; -import logger from './logger'; - -/**------------------------------------------------------------------------ - * onUrlChange -> onScroll - *------------------------------------------------------------------------**/ - -/*------------------------------- onKeyDown -----------------------------*/ - -const handleCtrlSpaceKeyDown = async (event: KeyboardEvent) => { - // ctrl + shift + space -> scroll to first - if (event.ctrlKey && event.code === 'Space') { - if (event.shiftKey) { - await scrollNextCommentIntoView(true); - } else { - await scrollNextCommentIntoView(); - } - } -}; - -/*-------------------------------- onScroll ------------------------------*/ - -const handleScroll = () => handleScrollDom(); -const debouncedScrollHandler = debounce(handleScroll, scrollDebounceWait); - -const handleUrlChange = async (previousUrl: string, currentUrl: string) => { - // modal or document - const scrollElement = getScrollElement(); - - if (hasArrivedToRedditThread(previousUrl, currentUrl)) { - scrollElement.addEventListener('scroll', debouncedScrollHandler); - - // listen keys on document - document.addEventListener('keydown', handleCtrlSpaceKeyDown); - - // test onUrlChange and onScroll independently - await handleUrlChangeDom(); - } - - if (hasLeftRedditThread(previousUrl, currentUrl)) { - scrollElement.removeEventListener('scroll', debouncedScrollHandler); - } -}; - -// must wait for redirect and page content load -const debouncedUrlChangeHandler = debounce(handleUrlChange, urlChangeDebounceWait); - -/*-------------------------------- onUrlChange ------------------------------*/ - -let previousUrl = ''; -const observer = new MutationObserver(async () => { - // string is primitive type, create backup - const previousUrlCopy = previousUrl; - const currentUrl = location.href; - - if (currentUrl !== previousUrl) { - previousUrl = currentUrl; - - // run on all pages to attach and detach scroll listeners - await debouncedUrlChangeHandler(previousUrlCopy, currentUrl); - } -}); - -const onUrlChange = () => { - observer.observe(document, { subtree: true, childList: true }); - document.addEventListener('beforeunload', () => observer.disconnect()); -}; - -/*---------------------- Listen to messages in contentScript --------------------*/ +} from '../dom/highlight-common'; +import { currentIndex } from '../dom/scroll-to-comment'; +import { clickSortByNewMenuItem } from '../dom/sort-by-new'; +import { getThreadIdFromDom } from '../dom/thread'; +import logger from '../logger'; +import { messageTypes, MyMessageType } from '../message'; +import { detectChanges, wait } from '../utils'; export const formSectionsKeys = { sectionTime: ['isHighlightOnTime', 'timeSlider', 'timeScale'], - sectionUnread: ['isHighlightUnread', 'unHighlightOn'], + sectionUnread: ['isHighlightUnread', 'unHighlightOn', 'markAllAsRead'], sectionScroll: ['scrollTo'], sectionSort: ['sortAllByNew'], sectionLogger: ['enableLogger'], @@ -153,9 +74,13 @@ const handleMessageFromPopup = async ( const db = await openDatabase(); // only correct place to get prev settings - const previousSettingsData = await getSettings(db); + const previousSettingsDbData = await getSettings(db); await updateSettings(db, settingsData); + // enhance with props that aren't in db, but only default values + const { markAllAsRead } = defaultValues; + const previousSettingsData = { ...previousSettingsDbData, markAllAsRead }; + const changedKeys = detectChanges(previousSettingsData, settingsData); const changedSections = Object.entries(formSectionsKeys) @@ -186,12 +111,12 @@ const handleMessageFromPopup = async ( // un-highlight on-scroll or url change case changedSections.includes('sectionUnread'): { - const { unHighlightOn, isHighlightUnread } = settingsData; + const { unHighlightOn, isHighlightUnread, markAllAsRead } = settingsData; if (changedKeys.includes('isHighlightUnread')) { if (isHighlightUnread === true) { const commentElements = getAllComments(); - await highlight(commentElements); + await highlightByRead(commentElements); } else { // disable main highlight removeHighlightClass(); @@ -205,9 +130,24 @@ const handleMessageFromPopup = async ( break; case 'on-scroll': const commentElements = getAllComments(); - await highlight(commentElements); + await highlightByRead(commentElements); break; } + + if (changedKeys.includes('markAllAsRead')) { + if (markAllAsRead === true) { + const commentIds = getAllCommentsIds(); + const threadIdFromDom = getThreadIdFromDom(); + await markCommentsAsReadInCurrentSessionForThread( + db, + threadIdFromDom, + commentIds + ); + const commentElements = getAllComments(); + await highlightByRead(commentElements); + } + } + break; } @@ -226,7 +166,7 @@ const handleMessageFromPopup = async ( const commentElements = getAllComments(); await highlightByDateWithSettingsData(commentElements); - await highlight(commentElements); + await highlightByRead(commentElements); } } break; @@ -255,7 +195,7 @@ const handleMessageFromPopup = async ( // reset current thread await updateCommentsFromPreviousSessionOrCreateThread(); const commentElements = getAllComments(); - await highlight(commentElements); + await highlightByRead(commentElements); break; } @@ -268,7 +208,7 @@ const handleMessageFromPopup = async ( // reset current thread await updateCommentsFromPreviousSessionOrCreateThread(); const commentElements = getAllComments(); - await highlight(commentElements); + await highlightByRead(commentElements); break; } @@ -312,24 +252,6 @@ const handleMessageFromPopup = async ( return; }; -const onReceiveMessage = () => { +export const onReceiveMessage = () => { browser.runtime.onMessage.addListener(handleMessageFromPopup); }; - -/*-------------------------------- Entry point ------------------------------*/ - -export const attachAllEventHandlers = async () => { - if (!isActiveTab()) return; - - // await truncateDatabase(); - - // create database - const db = await openDatabase(); - await initSettings(db); - - onReceiveMessage(); - onUrlChange(); -}; - -// rerun everything when tab gets focus -document.addEventListener('visibilitychange', attachAllEventHandlers); diff --git a/source/reddit-comments/events/on-scroll,.ts b/source/reddit-comments/events/on-scroll,.ts new file mode 100644 index 0000000..2091381 --- /dev/null +++ b/source/reddit-comments/events/on-scroll,.ts @@ -0,0 +1,8 @@ +import { scrollDebounceWait } from '../constants/config'; +import { handleScrollDom } from '../dom/handle-scroll'; +import { debounce } from '../utils'; + +/*-------------------------------- onScroll ------------------------------*/ + +const handleScroll = () => handleScrollDom(); +export const debouncedScrollHandler = debounce(handleScroll, scrollDebounceWait); diff --git a/source/reddit-comments/events/on-url-change.ts b/source/reddit-comments/events/on-url-change.ts new file mode 100644 index 0000000..b09bd27 --- /dev/null +++ b/source/reddit-comments/events/on-url-change.ts @@ -0,0 +1,45 @@ +import { urlChangeDebounceWait } from '../constants/config'; +import { handleUrlChangeDom } from '../dom/handle-url-change'; +import { debounce, hasArrivedToRedditThread, hasLeftRedditThread } from '../utils'; +import { handleCtrlSpaceKeyDown } from './on-key-down'; +import { debouncedScrollHandler } from './on-scroll,'; + +/*-------------------------------- onUrlChange ------------------------------*/ + +const handleUrlChange = async (previousUrl: string, currentUrl: string) => { + if (hasArrivedToRedditThread(previousUrl, currentUrl)) { + document.addEventListener('scroll', debouncedScrollHandler); + + // listen keys on document + document.addEventListener('keydown', handleCtrlSpaceKeyDown); + + // test onUrlChange and onScroll independently + await handleUrlChangeDom(); + } + + if (hasLeftRedditThread(previousUrl, currentUrl)) { + document.removeEventListener('scroll', debouncedScrollHandler); + } +}; + +// must wait for redirect and page content load +const debouncedUrlChangeHandler = debounce(handleUrlChange, urlChangeDebounceWait); + +let previousUrl = ''; +const observer = new MutationObserver(async () => { + // string is primitive type, create backup + const previousUrlCopy = previousUrl; + const currentUrl = location.href; + + if (currentUrl !== previousUrl) { + previousUrl = currentUrl; + + // run on all pages to attach and detach scroll listeners + await debouncedUrlChangeHandler(previousUrlCopy, currentUrl); + } +}); + +export const onUrlChange = () => { + observer.observe(document, { subtree: true, childList: true }); + document.addEventListener('beforeunload', () => observer.disconnect()); +}; diff --git a/source/reddit-comments/exceptions.ts b/source/reddit-comments/exceptions.ts index 253cd6f..0cbb234 100644 --- a/source/reddit-comments/exceptions.ts +++ b/source/reddit-comments/exceptions.ts @@ -39,6 +39,8 @@ export class MyElementNotFoundDOMException extends MyBaseDOMException {} export class MyElementIdNotValidDOMException extends MyBaseDOMException {} +export class MyElementAttributeNotValidDOMException extends MyBaseDOMException {} + /*--------------------------- Database Exceptions -------------------------*/ // not needed diff --git a/source/reddit-comments/index.ts b/source/reddit-comments/index.ts index 89bed6f..356e8b5 100644 --- a/source/reddit-comments/index.ts +++ b/source/reddit-comments/index.ts @@ -1,6 +1,5 @@ import { attachAllEventHandlers } from './events'; - -import './styles.scss'; // import only on reddit...? +import './styles.scss'; const main = () => { attachAllEventHandlers(); diff --git a/source/reddit-comments/message.ts b/source/reddit-comments/message.ts index 8ef7890..cf2ab79 100644 --- a/source/reddit-comments/message.ts +++ b/source/reddit-comments/message.ts @@ -14,6 +14,7 @@ export const messageTypes = { RESET_THREAD_DATA: 'RESET_THREAD_DATA', CALC_HIGHLIGHTED_ON_TIME_COUNT: 'CALC_HIGHLIGHTED_ON_TIME_COUNT', CALC_HIGHLIGHTED_UNREAD_COUNT: 'CALC_HIGHLIGHTED_UNREAD_COUNT', + MARK_ALL_AS_READ: 'MARK_ALL_AS_READ', GET_PAGE_URL: 'GET_PAGE_URL', } as const; diff --git a/source/reddit-comments/popup/popup.tsx b/source/reddit-comments/popup/popup.tsx index b44e86f..9cb984a 100644 --- a/source/reddit-comments/popup/popup.tsx +++ b/source/reddit-comments/popup/popup.tsx @@ -16,8 +16,9 @@ import { calcHighlightOnTimeDebounceWait, formSubmitDebounceWait, highlightedCommentsCountInterval, -} from '../constants'; -import { debounce, isRedditThread } from '../utils'; + markAllAsReadDbAndDomWait, +} from '../constants/config'; +import { debounce, isRedditThread, wait } from '../utils'; import { messageTypes, MyMessageType, sendMessage } from '../message'; import SectionLogger from './section-logger'; import logger from '../logger'; @@ -39,6 +40,7 @@ const Popup: FC = () => { const timeSlider = watch('timeSlider'); const isHighlightOnTime = watch('isHighlightOnTime'); const isHighlightUnread = watch('isHighlightUnread'); + const markAllAsRead = watch('markAllAsRead'); //! CANT USE DB, write generic function to get db data @@ -65,7 +67,9 @@ const Popup: FC = () => { const response: MyMessageType = await sendMessage(message); const settingsData: SettingsData = response.payload; - reset({ ...settingsData, resetDb: defaultValues.resetDb }); + const { resetDb, markAllAsRead } = defaultValues; + + reset({ ...settingsData, resetDb, markAllAsRead }); } catch (error) { logger.error('Populating settings failed, error:', error); } @@ -99,6 +103,28 @@ const Popup: FC = () => { onChange(); }, [isHighlightOnTime, timeScale, timeSlider]); + // re-calc unread comments after markAllAsRead + useEffect(() => { + const getHighlightedUnreadCount = async () => { + // wait for db and dom to update + await wait(markAllAsReadDbAndDomWait); + + const message: MyMessageType = { + type: messageTypes.CALC_HIGHLIGHTED_UNREAD_COUNT, + }; + const response: MyMessageType = await sendMessage(message); + const highlightedUnreadCount = response.payload; + + setHighlightedUnreadCount(highlightedUnreadCount); + + resetField('markAllAsRead'); + }; + + if (markAllAsRead) { + getHighlightedUnreadCount(); + } + }, [markAllAsRead]); + // refetch count of highlighted comments while popup is open useEffect(() => { const intervalFunction1 = async () => { @@ -192,7 +218,7 @@ const Popup: FC = () => {
- + diff --git a/source/reddit-comments/popup/section-link.tsx b/source/reddit-comments/popup/section-link.tsx index 3616eb4..d2b6a93 100644 --- a/source/reddit-comments/popup/section-link.tsx +++ b/source/reddit-comments/popup/section-link.tsx @@ -1,17 +1,28 @@ import React, { FC } from 'react'; -import { Flex, Link, Text } from '@radix-ui/themes'; +import { Flex, Link, Separator, Text } from '@radix-ui/themes'; const repoUrl = 'https://github.com/nemanjam/reddit-unread-comments'; const SectionLink: FC = () => { return ( - - - Feedback and suggestions: - - - {repoUrl} - + + + + Feedback and suggestions: + + + {repoUrl} + + + + + + Reddit Unread Comments + + + Version: 1.0.0 + + ); }; diff --git a/source/reddit-comments/popup/section-unread.tsx b/source/reddit-comments/popup/section-unread.tsx index 127b2c5..352127c 100644 --- a/source/reddit-comments/popup/section-unread.tsx +++ b/source/reddit-comments/popup/section-unread.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import { Controller, UseFormReturn } from 'react-hook-form'; -import { Flex, Text, RadioGroup, Switch, Box } from '@radix-ui/themes'; +import { Flex, Text, RadioGroup, Switch, Box, Checkbox } from '@radix-ui/themes'; import { SettingsData } from '../database/schema'; @@ -33,7 +33,7 @@ const SectionUnread: FC = ({ form, count }) => { - Unhighlight on: + Mark as read: = ({ form, count }) => { )} /> + ( + + + + Immediately + + + )} + /> ); }; diff --git a/source/reddit-comments/styles.scss b/source/reddit-comments/styles.scss index 40e4e77..871e673 100644 --- a/source/reddit-comments/styles.scss +++ b/source/reddit-comments/styles.scss @@ -2,14 +2,17 @@ /*-------------------------------- from database ------------------------------*/ -.ruc-highlight-comment [data-testid='comment'] { +// outline doesn't affect size, border does +// transition requires correct (same) both start and end css rules + +.ruc-highlight-comment > div[slot='comment'] { @apply bg-red-100; - @apply border border-solid rounded border-red-300; + @apply outline-1 outline outline-red-300; @apply transition-none; } -.ruc-highlight-comment-read [data-testid='comment'] { - @apply bg-transparent; +.ruc-highlight-comment-read > div[slot='comment'] { + @apply bg-transparent outline-transparent; @apply transition duration-1000 ease-linear; @apply hover:bg-red-100 hover:transition-none; } @@ -17,7 +20,7 @@ /*------------------------------ from dom, by date ----------------------------*/ // maybe just border around timestamp? -.ruc-highlight-comment-by-date [data-testid='comment_timestamp'] { +.ruc-highlight-comment-by-date a:has(time) { @apply bg-yellow-100; @apply border border-solid rounded border-yellow-300; } diff --git a/source/reddit-comments/validation.ts b/source/reddit-comments/validation.ts index c16cee0..4ba146a 100644 --- a/source/reddit-comments/validation.ts +++ b/source/reddit-comments/validation.ts @@ -1,5 +1,6 @@ import { MyElementIdNotValidDOMException } from './exceptions'; import { commentIdRegexValidate, threadPostIdRegexValidate } from './constants'; +import { commentIdAttribute } from './constants/selectors'; /** Returns boolean. */ export const validateThreadId = (threadId: string): boolean => @@ -16,7 +17,10 @@ export const validateThreadElementIdOrThrow = (threadElement: HTMLElement): stri }; export const validateCommentElementIdOrThrow = (commentElement: HTMLElement): string => { - if (!validateCommentId(commentElement.id)) - throw new MyElementIdNotValidDOMException(`Invalid Comment.id: ${commentElement.id}`); - return commentElement.id; + const commentId = commentElement.getAttribute(commentIdAttribute); + + if (!(commentId && validateCommentId(commentId))) + throw new MyElementIdNotValidDOMException(`Invalid Comment.thingid: ${commentId}`); + + return commentId; }; diff --git a/webpack.config.js b/webpack.config.js index 2927f65..1cc65f0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -62,6 +62,7 @@ module.exports = { entry: { manifest: path.join(sourcePath, 'manifest.json'), background: path.join(sourcePath, 'Background', 'index.ts'), + bgServiceWorker: path.join(sourcePath, 'Background', 'service-worker.ts'), contentScript: path.join(sourcePath, 'ContentScript', 'index.ts'), popup: path.join(sourcePath, 'Popup', 'index.tsx'), options: path.join(sourcePath, 'Options', 'index.tsx'), diff --git a/yarn.lock b/yarn.lock index fb450fe..2f8364a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,7 +7,7 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== -"@abhijithvijayan/eslint-config-airbnb@^1.0.2": +"@abhijithvijayan/eslint-config-airbnb@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@abhijithvijayan/eslint-config-airbnb/-/eslint-config-airbnb-1.0.2.tgz#f2fd992f7aea5e38dd17f69219696559fd60b610" integrity sha512-z7RQadE1sEiiygtrjYrfx/VTK6K/v74h5RL1QVgWxyMW+itl5tkzAyM5v+Fa9d0+y7SurSDDXHJqOnxBOeJieQ== @@ -20,7 +20,7 @@ resolved "https://registry.yarnpkg.com/@abhijithvijayan/eslint-config/-/eslint-config-2.6.3.tgz#3f560c99ce4e9631a404d956c4396835e1e52aa7" integrity sha512-njS8R0Q4FAtealxY48CmL8Ms58xPPqTZAcabB+92mogmfVkkHOlgb8kHfhW1PZpbe9xhHkKR5HwrkdMPNbMoiA== -"@abhijithvijayan/tsconfig@^1.3.0": +"@abhijithvijayan/tsconfig@1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@abhijithvijayan/tsconfig/-/tsconfig-1.3.0.tgz#cbf70537884dbf61872a1e1645f73f5e6f347edc" integrity sha512-hE16KPLQdygY07f50Lnb4QS2XvUV1anoCNQxsr3rNW2iOAJ/JUhAqIpYqbX6z6UK44ErYHtwUOeW9lOCDjucAQ==