aesy/reddit-comment-highlights

View on GitHub
src/reddit/RedesignRedditCommentHighlighter.ts

Summary

Maintainability
F
4 days
Test Coverage
import bind from "bind-decorator";
import { Logging } from "logger/Logging";
import { HighlighterOptions, RedditCommentHighlighter } from 'reddit/RedditCommentHighlighter';
import { RedditComment } from 'reddit/RedditPage';
import { hexToRgb, relativeLuminance } from "util/Color";
import { injectCSS } from "util/DOM";
import { wait } from 'util/Time';

const logger = Logging.getLogger("RedesignRedditCommentHighlighter");

export class RedesignRedditCommentHighlighter implements RedditCommentHighlighter {
    private darkModeObserver: MutationObserver | null = null;
    private cssElement: Element | null = null;

    public constructor(
        private readonly options: HighlighterOptions
    ) {
        this.addCss();
    }

    public highlightComment(comment: RedditComment): void {
        logger.info("Highlighting comment", {
            id: comment.id,
            time: comment.time.toISOString(),
            className: this.options.className
        });

        comment.element.classList.add(this.options.className);
        comment.element.classList.add(`${ this.options.className }--transition`);

        if (!this.options.clearOnClick) {
            return;
        }

        logger.debug("Installing click listener");

        comment.onClick.once(async () => {
            logger.info("Comment clicked", {
                id: comment.id
            });

            // Comments to clear on click
            const clear: RedditComment[] = [];

            if (this.options.includeChildren) {
                const addComment = (comment: RedditComment): void => {
                    const comments = comment.getChildComments();

                    for (const comment of comments) {
                        addComment(comment);
                    }

                    clear.push(comment);
                };

                addComment(comment);
            } else {
                clear.push(comment);
            }

            logger.info("Clearing highlights", {
                count: String(clear.length)
            });

            for (const comment of clear) {
                comment.element.classList.remove(this.options.className);
            }

            // Transition class can't be removed before transition has finished
            await wait(this.options.transitionDurationSeconds * 1000 + 500);

            for (const comment of clear) {
                const className = `${ this.options.className }--transition`;
                comment.element.classList.remove(className);
            }
        });
    }

    public dispose(): void {
        logger.debug("Disposing comment highlighter");

        const elements = document.querySelectorAll(".Comment");

        logger.debug("Removing all highlights", {
            count: String(elements.length)
        });

        for (const element of elements) {
            element.classList.remove(this.options.className);
            element.classList.remove(`${ this.options.className }--transition`);
        }

        this.removeCss();
    }

    private addCss(): void {
        if (this.cssElement) {
            this.removeCss();
        }

        logger.info("Injecting CSS");

        const root = document.getElementById("2x-container");

        if (!root) {
            throw "Failed to inject CSS. Reason: 2x-container element not found.";
        }

        const element = root.firstElementChild;

        if (!element) {
            throw "Failed to inject CSS. Reason: 2x-container has no children.";
        }

        const style = element.getAttribute("style");
        let darkMode = false;

        logger.debug("Detecting whether dark mode is active");

        if (style) {
            const kvRegex = /--(\w+):#(\w+)/g;
            const matches: Record<string, string> = {};
            let kv: RegExpExecArray | null;

            while ((kv = kvRegex.exec(style)) !== null) {
                matches[ kv[ 1 ] ] = kv[ 2 ];
            }

            if (!Object.keys(matches).length) {
                logger.warn("Failed to detect whether dark mode is active, assuming false", {
                    reason: `Failed to parse style attribute '${ style }'`
                });
            } else if (!matches.background) {
                logger.warn("Failed to detect whether dark mode is active, assuming false", {
                    reason: `Background property missing from style attribute '${ style }'`
                });
            } else {
                const color = hexToRgb(matches.background);
                const luma = relativeLuminance(color);
                darkMode = luma < 0.5;

                logger.debug("Successfully detected if dark mode is active", { darkMode: String(darkMode) });
            }
        } else {
            logger.warn("Failed to detect whether dark mode is active", {
                reason: "2x-container child has no style"
            });
        }

        this.cssElement = injectCSS(this.getCSS(darkMode), document.head);

        logger.debug("Installing style observer");

        if (this.darkModeObserver == null) {
            this.darkModeObserver = new MutationObserver(this.onStyleChange);
        }

        this.darkModeObserver.observe(element, {
            attributes: true,
            attributeFilter: [ "style" ]
        });

        logger.info('Successfully injected CSS');
    }

    private removeCss(): void {
        logger.info("Removing CSS");

        if (!this.cssElement) {
            logger.info("No CSS to remove");

            return;
        }

        logger.debug("Uninstalling style observer");

        if (this.darkModeObserver !== null) {
            this.darkModeObserver.disconnect();
        }

        const removed = document.head.removeChild(this.cssElement);
        this.cssElement = null;

        if (removed) {
            logger.info("Successfully removed CSS");
        } else {
            logger.warn("No CSS was removed");
        }
    }

    private getCSS(darkMode: boolean): string {
        logger.debug("Generating CSS");

        if (this.options.customCSS) {
            logger.debug("Using custom CSS");

            return this.options.customCSS;
        }

        let css = `
            .Comment.${ this.options.className }--transition [data-testid="comment"] {
                transition-property: margin, padding, border, background-color, color;
                transition-duration: ${ this.options.transitionDurationSeconds }s;
            }

            .Comment.${ this.options.className } [data-testid="comment"] {
                margin-top: 4px;
                padding: 4px 10px;
                border: ${ this.options.border || '0' };
                border-radius: 4px;
                background-color: ${ darkMode ? this.options.backgroundColorDark : this.options.backgroundColor };
                color: ${ darkMode ? this.options.normalTextColorDark : this.options.normalTextColor };
            }
        `;

        if (this.options.linkTextColor && this.options.linkTextColorDark) {
            css += `
                .Comment.${ this.options.className } [data-testid="comment"] a {
                    color: ${ darkMode ? this.options.linkTextColorDark : this.options.linkTextColor };
                }
            `;
        }

        if (this.options.quoteTextColor && this.options.quoteTextColorDark) {
            css += `
                .Comment.${ this.options.className } [data-test-id="comment"] blockquote {
                    color: ${ darkMode ? this.options.quoteTextColorDark : this.options.quoteTextColor };
                }
            `;
        }

        logger.debug("Successfully generated CSS");

        return css;
    }

    @bind
    private onStyleChange(): void {
        logger.warn("Style change detected");

        this.removeCss();
        this.addCss();
    }
}