src/content/ContentScript.ts
import bind from "bind-decorator";
import { Actions } from "common/Actions";
import { Constants } from "common/Constants";
import { onSettingsChanged, onThreadVisitedEvent } from "common/Events";
import { extensionFunctionRegistry } from "common/Registries";
import { ThreadHistoryEntry } from "history/ThreadHistory";
import { CompoundSink } from 'logger/CompoundSink';
import { LogLevel } from "logger/Logger";
import { Logging } from "logger/Logging";
import { SentrySink } from 'logger/SentrySink';
import { Options } from "options/ExtensionOptions";
import { OldRedditCommentHighlighter } from "reddit/OldRedditCommentHighlighter";
import { OldRedditPage } from "reddit/OldRedditPage";
import { HighlighterOptions, RedditCommentHighlighter } from "reddit/RedditCommentHighlighter";
import { isAtRootLevel, RedditComment, RedditCommentThread, RedditPage } from "reddit/RedditPage";
import { RedesignRedditCommentHighlighter } from "reddit/RedesignRedditCommentHighlighter";
import { RedesignRedditPage } from "reddit/RedesignRedditPage";
const logger = Logging.getLogger("ContentScript");
export class ContentScript {
private currentThread: RedditCommentThread | null = null;
/**
* number === Unix timestamp
* null === First visit
* undefined === Not yet known
*/
private lastVisitedTimestamp: number | null | undefined = undefined;
private constructor(
private readonly reddit: RedditPage,
private readonly highlighter: RedditCommentHighlighter
) {
reddit.onThreadOpened.subscribe(this.onThreadVisited);
}
public static async start(): Promise<ContentScript> {
logger.info("Starting ContentScript");
const options = await extensionFunctionRegistry.invoke<void, Options>(Actions.GET_OPTIONS);
if (options.sendErrorReports) {
Logging.setSink(new CompoundSink([
Logging.getSink(),
new SentrySink("ContentScript")
]));
}
if (options.debug) {
logger.info("Enabling debug mode");
Logging.setLogLevel(LogLevel.DEBUG);
} else {
logger.info("Disabling debug mode");
Logging.setLogLevel(LogLevel.WARN);
}
let reddit: RedditPage;
let highlighter: RedditCommentHighlighter;
const highlightOptions: HighlighterOptions = {
backgroundColor: options.backColor,
backgroundColorDark: options.backNightColor,
border: options.border,
linkTextColor: options.linkColor,
linkTextColorDark: options.linkNightColor,
normalTextColor: options.frontColor,
normalTextColorDark: options.frontNightColor,
quoteTextColor: options.quoteTextColor,
quoteTextColorDark: options.quoteTextNightColor,
className: options.customCSSClassName,
clearOnClick: options.clearCommentOnClick,
customCSS: options.customCSS,
includeChildren: options.clearCommentincludeChildren,
transitionDurationSeconds: Constants.CSS_TRANSITION_DURATION_SECONDS
};
if (RedesignRedditPage.isSupported()) {
logger.info("Detected redesign reddit page");
reddit = new RedesignRedditPage(Constants.REDESIGN_EXTENSION_NAME);
highlighter = new RedesignRedditCommentHighlighter(highlightOptions);
} else if (OldRedditPage.isSupported()) {
logger.info("Detected old reddit page");
reddit = new OldRedditPage();
highlighter = new OldRedditCommentHighlighter(highlightOptions);
} else {
throw "Failed to start ContentScript. Reason: no suitable reddit page implementation found.";
}
const contentScript = new ContentScript(reddit, highlighter);
// Restart after settings changed
onSettingsChanged.once(async () => {
logger.info("Restarting ContentScript", {
reason: "ExtensionOptions changed"
});
contentScript.stop();
try {
await ContentScript.start();
} catch (error) {
logger.error("Failed to start BackgroundScript", { error: JSON.stringify(error) });
throw error;
}
});
logger.debug("Successfully started ContentScript");
return contentScript;
}
public stop(): void {
logger.info("Stopping ContentScript");
this.reddit.dispose();
this.highlighter.dispose();
if (this.currentThread) {
this.currentThread.dispose();
} else {
logger.debug("No thread to dispose");
}
logger.debug("Successfully stopped ContentScript");
}
@bind
private async onThreadVisited(thread: RedditCommentThread): Promise<void> {
logger.info("Thread visited", { threadId: thread.id });
this.lastVisitedTimestamp = undefined;
if (this.currentThread) {
logger.debug("Disposing previous thread", { threadId: this.currentThread.id });
this.currentThread.dispose();
}
this.currentThread = thread;
await this.highlightComments(...thread.getAllComments());
thread.onCommentAdded.subscribe(this.highlightComments);
if (isAtRootLevel()) {
// Only consider comment section viewed if at root level
onThreadVisitedEvent.dispatch(thread.id);
}
}
@bind
private async highlightComments(...comments: RedditComment[]): Promise<void> {
if (!this.currentThread) {
return;
}
if (this.lastVisitedTimestamp === undefined) {
logger.debug("Fetching thread history entry", {
thread: this.currentThread.id
});
const entry = await extensionFunctionRegistry.invoke<string, ThreadHistoryEntry | null>(
Actions.GET_THREAD_BY_ID, this.currentThread.id);
if (entry) {
// Caching lastVisitedTimestamp so that we don't have to do a lot of unnecessary
// inter-script communication.
this.lastVisitedTimestamp = entry.timestamp * 1000;
logger.debug("Thread history entry found", {
thread: this.currentThread.id,
lastVisited: new Date(this.lastVisitedTimestamp).toISOString()
});
} else {
this.lastVisitedTimestamp = null;
logger.debug("No thread history entry exists, meaning thread visited for the first time.", {
thread: this.currentThread.id
});
}
}
if (this.lastVisitedTimestamp === null) {
return;
}
const user = this.reddit.getLoggedInUser();
for (const comment of comments) {
if (comment.isDeleted()) {
// Don't highlight deleted comments
continue;
}
if (user && user === comment.author) {
// Don't highlight users' own comments
continue;
}
if (comment.time.valueOf() < this.lastVisitedTimestamp) {
// Comment already seen, skip
continue;
}
logger.debug("Highlighting comment", {
id: comment.id,
created: comment.time.toISOString()
});
this.highlighter.highlightComment(comment);
}
}
}