diff --git a/scripts/Actions/Diff.ts b/scripts/Actions/Diff.ts index 95cf1976..5ca226ee 100644 --- a/scripts/Actions/Diff.ts +++ b/scripts/Actions/Diff.ts @@ -1,5 +1,5 @@ +import requestIdleCallback from "scripts/Utils/requestIdleCallback" import AnimeNotifier from "../AnimeNotifier" -import { requestIdleCallback } from "../Utils" // Load export function load(arn: AnimeNotifier, element: HTMLElement) { diff --git a/scripts/Actions/Explore.ts b/scripts/Actions/Explore.ts index 2fc76e2e..6d618ab5 100644 --- a/scripts/Actions/Explore.ts +++ b/scripts/Actions/Explore.ts @@ -1,5 +1,6 @@ +import emptyPixel from "scripts/Utils/emptyPixel" +import findAll from "scripts/Utils/findAll" import AnimeNotifier from "../AnimeNotifier" -import { findAll } from "scripts/Utils" // Filter anime on explore page export function filterAnime(arn: AnimeNotifier, _: HTMLInputElement) { @@ -12,7 +13,7 @@ export function filterAnime(arn: AnimeNotifier, _: HTMLInputElement) { for(const element of findAll("anime-grid-image")) { const img = element as HTMLImageElement - img.src = arn.emptyPixel() + img.src = emptyPixel img.classList.remove("element-found") img.classList.remove("element-color-preview") } diff --git a/scripts/Actions/Notifications.ts b/scripts/Actions/Notifications.ts index 09f932ed..54e8b337 100644 --- a/scripts/Actions/Notifications.ts +++ b/scripts/Actions/Notifications.ts @@ -2,24 +2,24 @@ import AnimeNotifier from "../AnimeNotifier" // Enable notifications export async function enableNotifications(arn: AnimeNotifier, _: HTMLElement) { - if(!arn.user || !arn.user.dataset.id) { + if(!arn.user) { return } arn.statusMessage.showInfo("Enabling instant notifications...") - await arn.pushManager.subscribe(arn.user.dataset.id) + await arn.pushManager.subscribe(arn.user.id) arn.updatePushUI() arn.statusMessage.showInfo("Enabled instant notifications for this device.") } // Disable notifications export async function disableNotifications(arn: AnimeNotifier, _: HTMLElement) { - if(!arn.user || !arn.user.dataset.id) { + if(!arn.user) { return } arn.statusMessage.showInfo("Disabling instant notifications...") - await arn.pushManager.unsubscribe(arn.user.dataset.id) + await arn.pushManager.unsubscribe(arn.user.id) arn.updatePushUI() arn.statusMessage.showInfo("Disabled instant notifications for this device.") } diff --git a/scripts/Actions/Search.ts b/scripts/Actions/Search.ts index a8bfa136..660fafa3 100644 --- a/scripts/Actions/Search.ts +++ b/scripts/Actions/Search.ts @@ -1,6 +1,7 @@ +import Diff from "scripts/Diff" +import delay from "scripts/Utils/delay" +import requestIdleCallback from "scripts/Utils/requestIdleCallback" import AnimeNotifier from "../AnimeNotifier" -import { delay, requestIdleCallback } from "../Utils" -import Diff from "scripts/Diff"; // Search page reference let emptySearchHTML = "" @@ -111,7 +112,7 @@ export async function search(arn: AnimeNotifier, search: HTMLInputElement, evt?: searchPageTitle.textContent = document.title if(!term || term.length < 1) { - await arn.innerHTML(searchPage, emptySearchHTML) + await Diff.innerHTML(searchPage, emptySearchHTML) arn.app.emit("DOMContentLoaded") return } @@ -168,7 +169,7 @@ function showResponseInElement(arn: AnimeNotifier, url: string, typeName: string correctResponseRendered[typeName] = true } - await arn.innerHTML(element, html) + await Diff.innerHTML(element, html) arn.onNewContent(element) } } diff --git a/scripts/Actions/Theme.ts b/scripts/Actions/Theme.ts index addb8366..f2afda25 100644 --- a/scripts/Actions/Theme.ts +++ b/scripts/Actions/Theme.ts @@ -1,5 +1,5 @@ +import hexToHSL from "scripts/Utils/hexToHSL" import AnimeNotifier from "../AnimeNotifier" -import { hexToHSL } from "scripts/Utils" let currentThemeName = "light" let previewTimeoutID: number = 0 @@ -7,8 +7,8 @@ let previewTimeoutID: number = 0 // let themeWheelTimeoutID: number = 0 const themes = { - "light": {}, - "dark": { + light: {}, + dark: { "link-color-h": "45", "link-color-s": "100%", "link-color-l": "66%", @@ -117,7 +117,7 @@ export function applyThemeAndPreview(arn: AnimeNotifier, themeName: string) { clearTimeout(previewTimeoutID) // If it's the free light theme or a PRO user, nothing to do here - if(currentThemeName === "light" || (arn.user && arn.user.dataset.pro == "true")) { + if(currentThemeName === "light" || (arn.user && arn.user.IsPro())) { return } diff --git a/scripts/Actions/Upload.ts b/scripts/Actions/Upload.ts index b049a735..f22ff737 100644 --- a/scripts/Actions/Upload.ts +++ b/scripts/Actions/Upload.ts @@ -1,12 +1,13 @@ +import bytesHumanReadable from "scripts/Utils/bytesHumanReadable" +import uploadWithProgress from "scripts/Utils/uploadWithProgress" import AnimeNotifier from "../AnimeNotifier" -import { bytesHumanReadable, uploadWithProgress } from "../Utils" // Select file export function selectFile(arn: AnimeNotifier, button: HTMLButtonElement) { const fileType = button.dataset.type const endpoint = button.dataset.endpoint - if(endpoint === "/api/upload/user/cover" && arn.user && arn.user.dataset.pro !== "true") { + if(endpoint === "/api/upload/user/cover" && arn.user && !arn.user.IsPro()) { alert("Please buy a PRO account to use this feature.") return } diff --git a/scripts/Analytics.ts b/scripts/Analytics.ts index 30a2f53b..e54cd882 100644 --- a/scripts/Analytics.ts +++ b/scripts/Analytics.ts @@ -1,41 +1,39 @@ -export default class Analytics { - push() { - const analytics = { - general: { - timezoneOffset: new Date().getTimezoneOffset() - }, - screen: { - width: screen.width, - height: screen.height, - availableWidth: screen.availWidth, - availableHeight: screen.availHeight, - pixelRatio: window.devicePixelRatio - }, - system: { - cpuCount: navigator.hardwareConcurrency, - platform: navigator.platform - }, - connection: { - downLink: 0, - roundTripTime: 0, - effectiveType: "" - } +export function uploadAnalytics() { + const analytics = { + general: { + timezoneOffset: new Date().getTimezoneOffset() + }, + screen: { + availableHeight: screen.availHeight, + availableWidth: screen.availWidth, + height: screen.height, + pixelRatio: window.devicePixelRatio, + width: screen.width + }, + system: { + cpuCount: navigator.hardwareConcurrency, + platform: navigator.platform + }, + connection: { + downLink: 0, + effectiveType: "", + roundTripTime: 0 } - - if("connection" in navigator) { - const connection = navigator["connection"] as any - - analytics.connection = { - downLink: connection.downlink, - roundTripTime: connection.rtt, - effectiveType: connection.effectiveType - } - } - - fetch("/dark-flame-master", { - method: "POST", - credentials: "same-origin", - body: JSON.stringify(analytics) - }) } + + if("connection" in navigator) { + const connection = navigator["connection"] as any + + analytics.connection = { + downLink: connection.downlink, + roundTripTime: connection.rtt, + effectiveType: connection.effectiveType + } + } + + fetch("/dark-flame-master", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(analytics) + }) } diff --git a/scripts/AnimeNotifier.ts b/scripts/AnimeNotifier.ts index 2cf10a81..47a460ba 100644 --- a/scripts/AnimeNotifier.ts +++ b/scripts/AnimeNotifier.ts @@ -1,51 +1,50 @@ import * as actions from "./Actions" -import Analytics from "./Analytics" +import { uploadAnalytics } from "./Analytics" import Application from "./Application" import AudioPlayer from "./AudioPlayer" import { displayAiringDate, displayDate, displayTime } from "./DateView" import Diff from "./Diff" import ToolTip from "./Elements/tool-tip/tool-tip" -import InfiniteScroller from "./InfiniteScroller" +import infiniteScroll from "./infiniteScroll" import NotificationManager from "./NotificationManager" import PushManager from "./PushManager" -import ServerEvents from "./ServerEvents" +import receiveServerEvents from "./ServerEvent/receiveServerEvents" import ServiceWorkerManager from "./ServiceWorkerManager" import SideBar from "./SideBar" import StatusMessage from "./StatusMessage" -import TouchController from "./TouchController" -import { delay, findAll, findAllInside, requestIdleCallback, supportsWebP, swapElements } from "./Utils" +import User from "./User" +import delay from "./Utils/delay" +import emptyPixel from "./Utils/emptyPixel" +import findAll from "./Utils/findAll" +import findAllInside from "./Utils/findAllInside" +import requestIdleCallback from "./Utils/requestIdleCallback" +import supportsWebP from "./Utils/supportsWebP" +import swapElements from "./Utils/swapElements" import VideoPlayer from "./VideoPlayer" import * as WebComponents from "./WebComponents" export default class AnimeNotifier { - app: Application - analytics: Analytics - user: HTMLElement | null - title: string - webpCheck: Promise - webpEnabled: boolean - contentLoadedActions: Promise - statusMessage: StatusMessage - visibilityObserver: IntersectionObserver - pushManager: PushManager - serviceWorkerManager: ServiceWorkerManager - notificationManager: NotificationManager | undefined - touchController: TouchController - audioPlayer: AudioPlayer - videoPlayer: VideoPlayer - sideBar: SideBar - infiniteScroller: InfiniteScroller - mainPageLoaded: boolean - isLoading: boolean - diffCompletedForCurrentPath: boolean - lastReloadContentPath: string - currentMediaId: string - serverEvents: ServerEvents - tip: ToolTip + public isLoading: boolean + public app: Application + public statusMessage: StatusMessage + public notificationManager: NotificationManager | undefined + public currentMediaId: string + public audioPlayer: AudioPlayer + public videoPlayer: VideoPlayer + public user: User | null + public sideBar: SideBar + public pushManager: PushManager - constructor(app: Application) { - this.app = app - this.user = null + private title: string + private webpCheck: Promise + private webpEnabled: boolean + private visibilityObserver: IntersectionObserver + private serviceWorkerManager: ServiceWorkerManager + private diffCompletedForCurrentPath: boolean + private tip: ToolTip + + constructor() { + this.app = new Application() this.title = "Anime Notifier" this.isLoading = true @@ -58,13 +57,13 @@ export default class AnimeNotifier { Diff.persistentAttributes.add("src") } - init() { + public init() { // App init this.app.init() // Event listeners - document.addEventListener("readystatechange", this.onReadyStateChange.bind(this)) - document.addEventListener("DOMContentLoaded", this.onContentLoaded.bind(this)) + document.addEventListener("readystatechange", () => this.onReadyStateChange()) + document.addEventListener("DOMContentLoaded", () => this.onContentLoaded()) // If we finished loading the DOM (either "interactive" or "complete" state), // immediately trigger the event listener functions. @@ -74,10 +73,318 @@ export default class AnimeNotifier { } // Idle - requestIdleCallback(this.onIdle.bind(this)) + requestIdleCallback(() => this.onIdle()) } - onReadyStateChange() { + public reloadContent(cached?: boolean) { + const headers = new Headers() + + if(cached) { + headers.set("X-Force-Cache", "true") + } else { + headers.set("X-No-Cache", "true") + } + + const path = this.app.currentPath + + return fetch("/_" + path, { + credentials: "same-origin", + headers + }) + .then(response => { + if(this.app.currentPath !== path) { + return Promise.reject("old request") + } + + return Promise.resolve(response) + }) + .then(response => response.text()) + .then(html => Diff.innerHTML(this.app.content, html)) + .then(() => this.app.emit("DOMContentLoaded")) + } + + public reloadPage() { + console.log("reload page", this.app.currentPath) + + const path = this.app.currentPath + + return fetch(path, { + credentials: "same-origin" + }) + .then(response => { + if(this.app.currentPath !== path) { + return Promise.reject("old request") + } + + return Promise.resolve(response) + }) + .then(response => response.text()) + .then(html => Diff.root(document.documentElement, html)) + .then(() => this.app.emit("DOMContentLoaded")) + .then(() => this.loading(false)) // Because our loading element gets reset due to full page diff + } + + public async diff(url: string) { + if(url === this.app.currentPath) { + return + } + + const path = "/_" + url + + try { + // Start the request + const request = fetch(path, { + credentials: "same-origin" + }) + .then(response => response.text()) + + history.pushState(url, "", url) + this.app.currentPath = url + this.diffCompletedForCurrentPath = false + this.app.markActiveLinks() + this.unmountMountables() + this.loading(true) + + // Delay by mountable-transition-speed + await delay(150) + + const html = await request + + // If the response for the correct path has not arrived yet, show this response + if(!this.diffCompletedForCurrentPath) { + // If this response was the most recently requested one, mark the requests as completed + if(this.app.currentPath === url) { + this.diffCompletedForCurrentPath = true + } + + // Update contents + await Diff.innerHTML(this.app.content, html) + this.app.emit("DOMContentLoaded") + } + } catch(err) { + console.error(err) + } finally { + this.loading(false) + } + } + + public post(url: string, body?: any) { + if(this.isLoading) { + return Promise.resolve(null) + } + + if(body !== undefined && typeof body !== "string") { + body = JSON.stringify(body) + } + + this.loading(true) + + return fetch(url, { + method: "POST", + body, + credentials: "same-origin" + }) + .then(response => { + this.loading(false) + + if(response.status === 200) { + return Promise.resolve(response) + } + + return response.text().then(err => { + throw err + }) + }) + .catch(err => { + this.loading(false) + throw err + }) + } + + public loading(newState: boolean) { + this.isLoading = newState + + if(this.isLoading) { + document.documentElement.style.cursor = "progress" + this.app.loading.classList.remove(this.app.fadeOutClass) + } else { + document.documentElement.style.cursor = "auto" + this.app.loading.classList.add(this.app.fadeOutClass) + } + } + + public onNewContent(element: HTMLElement) { + // Do the same as for the content loaded event, + // except here we are limiting it to the element. + this.app.ajaxify(element.getElementsByTagName("a")) + this.lazyLoad(findAllInside("lazy", element)) + this.mountMountables(findAllInside("mountable", element)) + this.prepareTooltips(findAllInside("tip", element)) + this.textAreaFocus() + } + + public scrollTo(target: HTMLElement) { + const duration = 250.0 + const fullSin = Math.PI / 2 + const contentPadding = 23 + + let newScroll = 0 + const finalScroll = Math.max(target.getBoundingClientRect().top - contentPadding, 0) + + // Calculating scrollTop will force a layout - careful! + const contentContainer = this.app.content.parentElement as HTMLElement + const oldScroll = contentContainer.scrollTop + const scrollDistance = finalScroll - oldScroll + + if(scrollDistance > 0 && scrollDistance < 1) { + return + } + + const timeStart = Date.now() + const timeEnd = timeStart + duration + + const scroll = () => { + const time = Date.now() + let progress = (time - timeStart) / duration + + if(progress > 1.0) { + progress = 1.0 + } + + newScroll = oldScroll + scrollDistance * Math.sin(progress * fullSin) + contentContainer.scrollTop = newScroll + + if(time < timeEnd && newScroll !== finalScroll) { + window.requestAnimationFrame(scroll) + } + } + + window.requestAnimationFrame(scroll) + } + + public findAPIEndpoint(element: HTMLElement | null): string { + while(element) { + if(element.dataset.api !== undefined) { + return element.dataset.api + } + + element = element.parentElement + } + + this.statusMessage.showError("API object not found") + throw "API object not found" + } + + public markPlayingMedia() { + for(const element of findAll("media-play-area")) { + if(element.dataset.mediaId === this.currentMediaId) { + element.classList.add("playing") + } + } + } + + public mountMountables(elements?: IterableIterator) { + if(!elements) { + elements = findAll("mountable") + } + + this.modifyDelayed(elements, element => element.classList.add("mounted")) + } + + public unmountMountables() { + for(const element of findAll("mountable")) { + if(element.classList.contains("never-unmount")) { + continue + } + + Diff.mutations.queue(() => element.classList.remove("mounted")) + } + } + + public async updatePushUI() { + if(!this.app.currentPath.includes("/settings/notifications")) { + return + } + + const enableButton = document.getElementById("enable-notifications") as HTMLButtonElement + const disableButton = document.getElementById("disable-notifications") as HTMLButtonElement + const testButton = document.getElementById("test-notification") as HTMLButtonElement + + if(!this.pushManager.pushSupported) { + enableButton.classList.add("hidden") + disableButton.classList.add("hidden") + testButton.innerHTML = "Your browser doesn't support push notifications!" + return + } + + const subscription = await this.pushManager.subscription() + + if(subscription) { + enableButton.classList.add("hidden") + disableButton.classList.remove("hidden") + } else { + enableButton.classList.remove("hidden") + disableButton.classList.add("hidden") + } + } + + public assignActions() { + for(const element of findAll("action")) { + const actionTrigger = element.dataset.trigger + const actionName = element.dataset.action + + // Filter out invalid definitions + if(!actionTrigger || !actionName) { + continue + } + + const oldAction = element["action assigned"] + + if(oldAction) { + if(oldAction.trigger === actionTrigger && oldAction.action === actionName) { + continue + } + + element.removeEventListener(oldAction.trigger, oldAction.handler) + } + + // This prevents default actions on links + if(actionTrigger === "click" && element.tagName === "A") { + element.onclick = null + } + + // Warn us about undefined actions + if(!(actionName in actions)) { + this.statusMessage.showError(`Action '${actionName}' has not been defined`) + continue + } + + // Register the actual action handler + const actionHandler = e => { + if(!actionName) { + return + } + + actions[actionName](this, element, e) + + e.stopPropagation() + e.preventDefault() + } + + element.addEventListener(actionTrigger, actionHandler) + + // Use "action assigned" flag instead of removing the class. + // This will make sure that DOM diffs which restore the class name + // will not assign the action multiple times to the same element. + element["action assigned"] = { + trigger: actionTrigger, + action: actionName, + handler: actionHandler + } + } + } + + private onReadyStateChange() { if(document.readyState !== "interactive") { return } @@ -85,23 +392,11 @@ export default class AnimeNotifier { this.run() } - run() { + private run() { // Initiate the elements we need - this.user = document.getElementById("user") this.app.content = document.getElementById("content") as HTMLElement this.app.loading = document.getElementById("loading") as HTMLElement - // Theme - if(this.user && this.user.dataset.pro === "true") { - const theme = this.user.dataset.theme - - // Don't apply light theme on load because - // it's already the standard theme. - if(theme && theme !== "light") { - actions.applyTheme(theme) - } - } - // Web components WebComponents.register() @@ -110,30 +405,18 @@ export default class AnimeNotifier { document.body.appendChild(this.tip) document.addEventListener("linkclicked", () => this.tip.classList.add("fade-out")) - // Intersection observer - if("IntersectionObserver" in window) { - // Enable lazy load - this.visibilityObserver = new IntersectionObserver( - entries => { - for(const entry of entries) { - if(entry.isIntersecting) { - entry.target["became visible"]() - this.visibilityObserver.unobserve(entry.target) - } + // Enable lazy load + this.visibilityObserver = new IntersectionObserver( + entries => { + for(const entry of entries) { + if(entry.isIntersecting) { + entry.target["became visible"]() + this.visibilityObserver.unobserve(entry.target) } - }, - {} - ) - } else { - // Disable lazy load feature - this.visibilityObserver = { - disconnect: () => {}, - observe: (elem: HTMLElement) => { - elem["became visible"]() - }, - unobserve: (_: HTMLElement) => {} - } as IntersectionObserver - } + } + }, + {} + ) // Status message this.statusMessage = new StatusMessage( @@ -145,6 +428,23 @@ export default class AnimeNotifier { this.statusMessage.showError(error, 3000) } + // User + const userElement = document.getElementById("user") + + if(userElement && userElement.dataset.id) { + this.user = new User(userElement.dataset.id) + + if(userElement.dataset.pro === "true") { + const theme = userElement.dataset.theme + + // Don't apply light theme on load because + // it's already the standard theme. + if(theme && theme !== "light") { + actions.applyTheme(theme) + } + } + } + // Push manager this.pushManager = new PushManager() @@ -162,14 +462,13 @@ export default class AnimeNotifier { // Video player this.videoPlayer = new VideoPlayer(this) - // Analytics - this.analytics = new Analytics() - // Sidebar control this.sideBar = new SideBar(document.getElementById("sidebar")) // Infinite scrolling - this.infiniteScroller = new InfiniteScroller(this.app.content.parentElement, 150) + if(this.app.content.parentElement) { + infiniteScroll(this.app.content.parentElement, 150) + } // WebP this.webpCheck = supportsWebP().then(val => this.webpEnabled = val) @@ -178,11 +477,11 @@ export default class AnimeNotifier { this.loading(false) } - onContentLoaded() { + private onContentLoaded() { // Stop watching all the objects from the previous page. this.visibilityObserver.disconnect() - this.contentLoadedActions = Promise.all([ + Promise.all([ Promise.resolve().then(() => this.mountMountables()), Promise.resolve().then(() => this.lazyLoad()), Promise.resolve().then(() => this.displayLocalDates()), @@ -211,7 +510,7 @@ export default class AnimeNotifier { } } - applyPageTitle() { + private applyPageTitle() { const headers = document.getElementsByTagName("h1") if(this.app.currentPath === "/" || headers.length === 0 || headers[0].textContent === "NOTIFY.MOE") { @@ -223,7 +522,7 @@ export default class AnimeNotifier { } } - textAreaFocus() { + private textAreaFocus() { const newPostText = document.getElementById("new-post-text") as HTMLTextAreaElement if(!newPostText || newPostText["has-input-listener"]) { @@ -243,7 +542,7 @@ export default class AnimeNotifier { newPostText["has-input-listener"] = true } - async onIdle() { + private async onIdle() { // Register event listeners document.addEventListener("keydown", this.onKeyDown.bind(this), false) window.addEventListener("popstate", this.onPopState.bind(this)) @@ -255,7 +554,7 @@ export default class AnimeNotifier { // Analytics if(this.user) { - this.analytics.push() + uploadAnalytics() } // Offline message @@ -289,7 +588,7 @@ export default class AnimeNotifier { // Server sent events if(this.user && EventSource) { - this.serverEvents = new ServerEvents(this) + receiveServerEvents(this) } // // Download popular anime titles for the search @@ -310,7 +609,7 @@ export default class AnimeNotifier { // search.setAttribute("list", titleList.id) } - onBeforeUnload(e: BeforeUnloadEvent) { + private onBeforeUnload(e: BeforeUnloadEvent) { if(this.app.currentPath !== "/new/thread") { return } @@ -331,7 +630,7 @@ export default class AnimeNotifier { e.returnValue = "You have unsaved changes on the current page. Are you sure you want to leave?" } - prepareTooltips(elements?: IterableIterator) { + private prepareTooltips(elements?: IterableIterator) { if(!elements) { elements = findAll("tip") } @@ -351,7 +650,7 @@ export default class AnimeNotifier { } } - dragAndDrop() { + private dragAndDrop() { if(location.pathname.includes("/animelist/")) { for(const listItem of findAll("anime-list-item")) { // Skip elements that have their event listeners attached already @@ -562,34 +861,7 @@ export default class AnimeNotifier { } } - async updatePushUI() { - if(!this.app.currentPath.includes("/settings/notifications")) { - return - } - - const enableButton = document.getElementById("enable-notifications") as HTMLButtonElement - const disableButton = document.getElementById("disable-notifications") as HTMLButtonElement - const testButton = document.getElementById("test-notification") as HTMLButtonElement - - if(!this.pushManager.pushSupported) { - enableButton.classList.add("hidden") - disableButton.classList.add("hidden") - testButton.innerHTML = "Your browser doesn't support push notifications!" - return - } - - const subscription = await this.pushManager.subscription() - - if(subscription) { - enableButton.classList.add("hidden") - disableButton.classList.remove("hidden") - } else { - enableButton.classList.remove("hidden") - disableButton.classList.add("hidden") - } - } - - loadCharacterRanking() { + private loadCharacterRanking() { if(!this.app.currentPath.includes("/character/")) { return } @@ -612,7 +884,7 @@ export default class AnimeNotifier { } } - colorBoxes() { + private colorBoxes() { if(!this.app.currentPath.includes("/explore/color/") && !this.app.currentPath.includes("/settings")) { return } @@ -629,7 +901,7 @@ export default class AnimeNotifier { } } - countUp() { + private countUp() { if(!this.app.currentPath.includes("/paypal/success")) { return } @@ -640,7 +912,7 @@ export default class AnimeNotifier { continue } - const final = parseInt(element.textContent) + const final = parseInt(element.textContent, 10) const duration = 2000.0 const start = Date.now() @@ -664,15 +936,7 @@ export default class AnimeNotifier { } } - markPlayingMedia() { - for(const element of findAll("media-play-area")) { - if(element.dataset.mediaId === this.currentMediaId) { - element.classList.add("playing") - } - } - } - - setSelectBoxValue() { + private setSelectBoxValue() { for(const element of document.getElementsByTagName("select")) { const attributeValue = element.getAttribute("value") @@ -685,7 +949,7 @@ export default class AnimeNotifier { } } - displayLocalDates() { + private displayLocalDates() { const now = new Date() for(const element of findAll("utc-airing-date")) { @@ -701,124 +965,7 @@ export default class AnimeNotifier { } } - reloadContent(cached?: boolean) { - const headers = new Headers() - - if(cached) { - headers.set("X-Force-Cache", "true") - } else { - headers.set("X-No-Cache", "true") - } - - const path = this.lastReloadContentPath = this.app.currentPath - - return fetch("/_" + path, { - credentials: "same-origin", - headers - }) - .then(response => { - if(this.app.currentPath !== path) { - return Promise.reject("old request") - } - - return Promise.resolve(response) - }) - .then(response => response.text()) - .then(html => Diff.innerHTML(this.app.content, html)) - .then(() => this.app.emit("DOMContentLoaded")) - } - - reloadPage() { - console.log("reload page", this.app.currentPath) - - const path = this.app.currentPath - this.lastReloadContentPath = path - - return fetch(path, { - credentials: "same-origin" - }) - .then(response => { - if(this.app.currentPath !== path) { - return Promise.reject("old request") - } - - return Promise.resolve(response) - }) - .then(response => response.text()) - .then(html => Diff.root(document.documentElement, html)) - .then(() => this.app.emit("DOMContentLoaded")) - .then(() => this.loading(false)) // Because our loading element gets reset due to full page diff - } - - loading(newState: boolean) { - this.isLoading = newState - - if(this.isLoading) { - document.documentElement.style.cursor = "progress" - this.app.loading.classList.remove(this.app.fadeOutClass) - } else { - document.documentElement.style.cursor = "auto" - this.app.loading.classList.add(this.app.fadeOutClass) - } - } - - assignActions() { - for(const element of findAll("action")) { - const actionTrigger = element.dataset.trigger - const actionName = element.dataset.action - - // Filter out invalid definitions - if(!actionTrigger || !actionName) { - continue - } - - const oldAction = element["action assigned"] - - if(oldAction) { - if(oldAction.trigger === actionTrigger && oldAction.action === actionName) { - continue - } - - element.removeEventListener(oldAction.trigger, oldAction.handler) - } - - // This prevents default actions on links - if(actionTrigger === "click" && element.tagName === "A") { - element.onclick = null - } - - // Warn us about undefined actions - if(!(actionName in actions)) { - this.statusMessage.showError(`Action '${actionName}' has not been defined`) - continue - } - - // Register the actual action handler - const actionHandler = e => { - if(!actionName) { - return - } - - actions[actionName](this, element, e) - - e.stopPropagation() - e.preventDefault() - } - - element.addEventListener(actionTrigger, actionHandler) - - // Use "action assigned" flag instead of removing the class. - // This will make sure that DOM diffs which restore the class name - // will not assign the action multiple times to the same element. - element["action assigned"] = { - trigger: actionTrigger, - action: actionName, - handler: actionHandler - } - } - } - - async lazyLoad(elements?: IterableIterator) { + private async lazyLoad(elements?: IterableIterator) { if(!elements) { elements = findAll("lazy") } @@ -842,11 +989,7 @@ export default class AnimeNotifier { } } - emptyPixel() { - return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" - } - - lazyLoadImage(element: HTMLImageElement) { + private lazyLoadImage(element: HTMLImageElement) { const pixelRatio = window.devicePixelRatio // Once the image becomes visible, load it @@ -887,7 +1030,7 @@ export default class AnimeNotifier { if(element.src !== finalSrc && element.src !== "https:" + finalSrc && element.src !== "https://notify.moe" + finalSrc) { // Show average color if(element.dataset.color) { - element.src = this.emptyPixel() + element.src = emptyPixel element.style.backgroundColor = element.dataset.color Diff.mutations.queue(() => element.classList.add("element-color-preview")) } @@ -927,7 +1070,7 @@ export default class AnimeNotifier { this.visibilityObserver.observe(element) } - lazyLoadIFrame(element: HTMLIFrameElement) { + private lazyLoadIFrame(element: HTMLIFrameElement) { // Once the iframe becomes visible, load it element["became visible"] = () => { if(!element.dataset.src) { @@ -946,7 +1089,7 @@ export default class AnimeNotifier { this.visibilityObserver.observe(element) } - lazyLoadVideo(video: HTMLVideoElement) { + private lazyLoadVideo(video: HTMLVideoElement) { const hideControlsDelay = 1500 // Once the video becomes visible, load it @@ -1075,27 +1218,9 @@ export default class AnimeNotifier { this.visibilityObserver.observe(video) } - mountMountables(elements?: IterableIterator) { - if(!elements) { - elements = findAll("mountable") - } - - this.modifyDelayed(elements, element => element.classList.add("mounted")) - } - - unmountMountables() { - for(const element of findAll("mountable")) { - if(element.classList.contains("never-unmount")) { - continue - } - - Diff.mutations.queue(() => element.classList.remove("mounted")) - } - } - - modifyDelayed(elements: IterableIterator, func: (element: HTMLElement) => void) { + private modifyDelayed(elements: IterableIterator, func: (element: HTMLElement) => void) { const maxDelay = 2500 - const delay = 20 + const delayTime = 20 let time = 0 const start = Date.now() @@ -1116,7 +1241,7 @@ export default class AnimeNotifier { const typeTime = mountableTypes.get(type) if(typeTime !== undefined) { - time = typeTime + delay + time = typeTime + delayTime mountableTypes.set(type, time) } else { time = start @@ -1161,162 +1286,7 @@ export default class AnimeNotifier { } } - async diff(url: string) { - if(url === this.app.currentPath) { - return - } - - const path = "/_" + url - - try { - // Start the request - const request = fetch(path, { - credentials: "same-origin" - }) - .then(response => response.text()) - - history.pushState(url, "", url) - this.app.currentPath = url - this.diffCompletedForCurrentPath = false - this.app.markActiveLinks() - this.unmountMountables() - this.loading(true) - - // Delay by mountable-transition-speed - await delay(150) - - const html = await request - - // If the response for the correct path has not arrived yet, show this response - if(!this.diffCompletedForCurrentPath) { - // If this response was the most recently requested one, mark the requests as completed - if(this.app.currentPath === url) { - this.diffCompletedForCurrentPath = true - } - - // Update contents - await Diff.innerHTML(this.app.content, html) - this.app.emit("DOMContentLoaded") - } - } catch(err) { - console.error(err) - } finally { - this.loading(false) - } - } - - innerHTML(element: HTMLElement, html: string) { - return Diff.innerHTML(element, html) - } - - post(url: string, body?: any) { - if(this.isLoading) { - return Promise.resolve(null) - } - - if(body !== undefined && typeof body !== "string") { - body = JSON.stringify(body) - } - - this.loading(true) - - return fetch(url, { - method: "POST", - body, - credentials: "same-origin" - }) - .then(response => { - this.loading(false) - - if(response.status === 200) { - return Promise.resolve(response) - } - - return response.text().then(err => { - throw err - }) - }) - .catch(err => { - this.loading(false) - throw err - }) - } - - onNewContent(element: HTMLElement) { - // Do the same as for the content loaded event, - // except here we are limiting it to the element. - this.app.ajaxify(element.getElementsByTagName("a")) - this.lazyLoad(findAllInside("lazy", element)) - this.mountMountables(findAllInside("mountable", element)) - this.prepareTooltips(findAllInside("tip", element)) - this.textAreaFocus() - } - - scrollTo(target: HTMLElement) { - const duration = 250.0 - const fullSin = Math.PI / 2 - const contentPadding = 23 - - let newScroll = 0 - const finalScroll = Math.max(target.getBoundingClientRect().top - contentPadding, 0) - - // Calculating scrollTop will force a layout - careful! - const contentContainer = this.app.content.parentElement as HTMLElement - const oldScroll = contentContainer.scrollTop - const scrollDistance = finalScroll - oldScroll - - if(scrollDistance > 0 && scrollDistance < 1) { - return - } - - const timeStart = Date.now() - const timeEnd = timeStart + duration - - const scroll = () => { - const time = Date.now() - let progress = (time - timeStart) / duration - - if(progress > 1.0) { - progress = 1.0 - } - - newScroll = oldScroll + scrollDistance * Math.sin(progress * fullSin) - contentContainer.scrollTop = newScroll - - if(time < timeEnd && newScroll != finalScroll) { - window.requestAnimationFrame(scroll) - } - } - - window.requestAnimationFrame(scroll) - } - - findAPIEndpoint(element: HTMLElement) { - if(element.dataset.api !== undefined) { - return element.dataset.api - } - - let apiObject: HTMLElement | undefined - let parent: HTMLElement | null - - parent = element - - while(parent = parent.parentElement) { - if(parent.dataset.api !== undefined) { - apiObject = parent - break - } - } - - if(!apiObject || !apiObject.dataset.api) { - this.statusMessage.showError("API object not found") - throw "API object not found" - } - - return apiObject.dataset.api - } - - onPopState(e: PopStateEvent) { + private onPopState(e: PopStateEvent) { if(e.state) { this.app.load(e.state, { addToHistory: false @@ -1328,7 +1298,7 @@ export default class AnimeNotifier { } } - onKeyDown(e: KeyboardEvent) { + private onKeyDown(e: KeyboardEvent) { const activeElement = document.activeElement if(!activeElement) { @@ -1360,7 +1330,7 @@ export default class AnimeNotifier { // Disallow Enter key in contenteditables and make it blur the element instead if(e.keyCode === 13) { if("blur" in activeElement) { - (activeElement["blur"] as Function)() + (activeElement["blur"] as () => void)() } return preventDefault() @@ -1395,15 +1365,14 @@ export default class AnimeNotifier { return preventDefault() } - // "+" = Audio speed up - if(e.key == "+") { + if(e.key === "+") { this.audioPlayer.addSpeed(0.05) return preventDefault() } // "-" = Audio speed down - if(e.key == "-") { + if(e.key === "-") { this.audioPlayer.addSpeed(-0.05) return preventDefault() } @@ -1450,7 +1419,7 @@ export default class AnimeNotifier { } // This is called every time an uncaught JavaScript error is thrown - async onError(evt: ErrorEvent) { + private async onError(evt: ErrorEvent) { const report = { message: evt.message, stack: evt.error.stack, diff --git a/scripts/Application.ts b/scripts/Application.ts index 2b5afecf..9f6c014e 100644 --- a/scripts/Application.ts +++ b/scripts/Application.ts @@ -1,6 +1,6 @@ import Diff from "./Diff" import LoadOptions from "./LoadOptions" -import { delay } from "./Utils" +import delay from "./Utils/delay" export default class Application { public originalPath: string diff --git a/scripts/DateView.ts b/scripts/DateView.ts index 2ebe9614..8044fdf3 100644 --- a/scripts/DateView.ts +++ b/scripts/DateView.ts @@ -1,4 +1,4 @@ -import { plural } from "./Utils" +import plural from "./Utils/plural" const oneSecond = 1000 const oneMinute = 60 * oneSecond diff --git a/scripts/InfiniteScroller.ts b/scripts/InfiniteScroller.ts deleted file mode 100644 index 672010e1..00000000 --- a/scripts/InfiniteScroller.ts +++ /dev/null @@ -1,39 +0,0 @@ -import Diff from "./Diff" - -export default class InfiniteScroller { - container: HTMLElement - threshold: number - - constructor(container, threshold) { - this.container = container - this.threshold = threshold - - const check = () => { - if(this.container.scrollTop + this.container.clientHeight >= this.container.scrollHeight - threshold) { - this.loadMore() - } - } - - this.container.addEventListener("scroll", _ => { - // Wait for mutations to finish before checking if we need infinite scroll to trigger. - if(Diff.mutations.mutations.length > 0) { - Diff.mutations.wait(() => check()) - return - } - - // Otherwise, queue up the check immediately. - // Don't call check() directly to make scrolling as smooth as possible. - Diff.mutations.queue(check) - }) - } - - loadMore() { - const button = document.getElementById("load-more-button") - - if(!button) { - return - } - - button.click() - } -} diff --git a/scripts/MutationQueue.ts b/scripts/MutationQueue.ts index 7e4992e4..80437c39 100644 --- a/scripts/MutationQueue.ts +++ b/scripts/MutationQueue.ts @@ -9,15 +9,15 @@ const timeCapacity = 6.5 // It checks the time used to process these mutations and if the time is over the // defined time capacity, it will pause and continue the mutations in the next frame. export default class MutationQueue { - mutations: Array<() => void> - onClearCallBacks: Array<() => void> + private mutations: Array<() => void> + private onClearCallBacks: Array<() => void> constructor() { this.mutations = [] this.onClearCallBacks = [] } - queue(mutation: () => void) { + public queue(mutation: () => void) { this.mutations.push(mutation) if(this.mutations.length === 1) { @@ -25,7 +25,20 @@ export default class MutationQueue { } } - mutateAll() { + public wait(callBack: () => void) { + if(this.mutations.length === 0) { + callBack() + return + } + + this.onClearCallBacks.push(callBack) + } + + public length() { + return this.mutations.length + } + + private mutateAll() { const start = performance.now() for(let i = 0; i < this.mutations.length; i++) { @@ -45,7 +58,7 @@ export default class MutationQueue { this.clear() } - clear() { + private clear() { this.mutations.length = 0 if(this.onClearCallBacks.length > 0) { @@ -56,13 +69,4 @@ export default class MutationQueue { this.onClearCallBacks.length = 0 } } - - wait(callBack: () => void) { - if(this.mutations.length === 0) { - callBack() - return - } - - this.onClearCallBacks.push(callBack) - } } diff --git a/scripts/ServerEvent/ServerEvent.ts b/scripts/ServerEvent/ServerEvent.ts new file mode 100644 index 00000000..e281ae49 --- /dev/null +++ b/scripts/ServerEvent/ServerEvent.ts @@ -0,0 +1,3 @@ +export default class ServerEvent { + public data: string +} diff --git a/scripts/ServerEvent/receiveServerEvents.ts b/scripts/ServerEvent/receiveServerEvents.ts new file mode 100644 index 00000000..b9cef4fc --- /dev/null +++ b/scripts/ServerEvent/receiveServerEvents.ts @@ -0,0 +1,95 @@ +import AnimeNotifier from "../AnimeNotifier" +import plural from "../Utils/plural" +import ServerEvent from "./ServerEvent" + +const reconnectDelay = 3000 + +let supported: boolean +let eventSource: EventSource +let arn: AnimeNotifier +let etags: Map + +export default function receiveServerEvents(animeNotifier: AnimeNotifier) { + supported = ("EventSource" in window) + + if(!supported) { + return + } + + arn = animeNotifier + etags = new Map() + connect() +} + +function connect() { + if(eventSource) { + eventSource.close() + } + + eventSource = new EventSource("/api/sse/events", { + withCredentials: true + }) + + eventSource.addEventListener("ping", (e: any) => ping(e)) + eventSource.addEventListener("etag", (e: any) => etag(e)) + eventSource.addEventListener("activity", (e: any) => activity(e)) + eventSource.addEventListener("notificationCount", (e: any) => notificationCount(e)) + + eventSource.onerror = () => { + setTimeout(() => connect(), reconnectDelay) + } +} + +function ping(_: ServerEvent) { + console.log("ping") +} + +function etag(e: ServerEvent) { + const data = JSON.parse(e.data) + const oldETag = etags.get(data.url) + const newETag = data.etag + + if(oldETag && newETag && oldETag !== newETag) { + arn.statusMessage.showInfo("A new version of the website is available. Please refresh the page.", -1) + } + + etags.set(data.url, newETag) +} + +function activity(e: ServerEvent) { + if(!location.pathname.startsWith("/activity")) { + return + } + + const isFollowingUser = JSON.parse(e.data) + + // If we're on the followed only feed and we receive an activity + // about a user we don't follow, ignore the message. + if(location.pathname.startsWith("/activity/followed") && !isFollowingUser) { + return + } + + const button = document.getElementById("load-new-activities") + + if(!button || !button.dataset.count) { + return + } + + const buttonText = document.getElementById("load-new-activities-text") + + if(!buttonText) { + return + } + + const newCount = parseInt(button.dataset.count, 10) + 1 + button.dataset.count = newCount.toString() + buttonText.textContent = plural(newCount, "new activity") +} + +function notificationCount(e: ServerEvent) { + if(!arn.notificationManager) { + return + } + + arn.notificationManager.setCounter(parseInt(e.data, 10)) +} diff --git a/scripts/ServerEvents.ts b/scripts/ServerEvents.ts deleted file mode 100644 index c7bf40a7..00000000 --- a/scripts/ServerEvents.ts +++ /dev/null @@ -1,100 +0,0 @@ -import AnimeNotifier from "./AnimeNotifier" -import { plural } from "./Utils" - -const reconnectDelay = 3000 - -class ServerEvent { - data: string -} - -export default class ServerEvents { - supported: boolean - eventSource: EventSource - arn: AnimeNotifier - etags: Map - - constructor(arn: AnimeNotifier) { - this.supported = ("EventSource" in window) - - if(!this.supported) { - return - } - - this.arn = arn - this.etags = new Map() - this.connect() - } - - connect() { - if(this.eventSource) { - this.eventSource.close() - } - - this.eventSource = new EventSource("/api/sse/events", { - withCredentials: true - }) - - this.eventSource.addEventListener("ping", (e: any) => this.ping(e)) - this.eventSource.addEventListener("etag", (e: any) => this.etag(e)) - this.eventSource.addEventListener("activity", (e: any) => this.activity(e)) - this.eventSource.addEventListener("notificationCount", (e: any) => this.notificationCount(e)) - - this.eventSource.onerror = () => { - setTimeout(() => this.connect(), reconnectDelay) - } - } - - ping(_: ServerEvent) { - console.log("ping") - } - - etag(e: ServerEvent) { - const data = JSON.parse(e.data) - const oldETag = this.etags.get(data.url) - const newETag = data.etag - - if(oldETag && newETag && oldETag != newETag) { - this.arn.statusMessage.showInfo("A new version of the website is available. Please refresh the page.", -1) - } - - this.etags.set(data.url, newETag) - } - - activity(e: ServerEvent) { - if(!location.pathname.startsWith("/activity")) { - return - } - - const isFollowingUser = JSON.parse(e.data) - - // If we're on the followed only feed and we receive an activity - // about a user we don't follow, ignore the message. - if(location.pathname.startsWith("/activity/followed") && !isFollowingUser) { - return - } - - const button = document.getElementById("load-new-activities") - - if(!button || !button.dataset.count) { - return - } - - const buttonText = document.getElementById("load-new-activities-text") - - if(!buttonText) { - return - } - - const newCount = parseInt(button.dataset.count) + 1 - button.dataset.count = newCount.toString() - buttonText.textContent = plural(newCount, "new activity") - } - - notificationCount(e: ServerEvent) { - if(!this.arn.notificationManager) { - return - } - - this.arn.notificationManager.setCounter(parseInt(e.data)) - } -} diff --git a/scripts/ServiceWorkerManager.ts b/scripts/ServiceWorkerManager.ts index 1521895e..990f993e 100644 --- a/scripts/ServiceWorkerManager.ts +++ b/scripts/ServiceWorkerManager.ts @@ -1,66 +1,25 @@ import AnimeNotifier from "./AnimeNotifier" export default class ServiceWorkerManager { - arn: AnimeNotifier - uri: string + private arn: AnimeNotifier + private uri: string constructor(arn: AnimeNotifier, uri: string) { this.arn = arn this.uri = uri } - register() { + public register() { if(!("serviceWorker" in navigator)) { console.warn("service worker not supported, skipping registration") return } navigator.serviceWorker.register(this.uri) - - navigator.serviceWorker.addEventListener("message", evt => { - this.onMessage(evt) - }) - - // This will send a message to the service worker that the DOM has been loaded - const sendContentLoadedEvent = () => { - if(!navigator.serviceWorker.controller) { - return - } - - // A reloadContent call should never trigger another reload - if(this.arn.app.currentPath === this.arn.lastReloadContentPath) { - this.arn.lastReloadContentPath = "" - return - } - - let url = "" - - // If mainPageLoaded is set, it means every single request is now an AJAX request for the /_/ prefixed page - if(this.arn.mainPageLoaded) { - url = window.location.origin + "/_" + window.location.pathname - } else { - this.arn.mainPageLoaded = true - url = window.location.href - } - - // console.log("checking for updates:", message.url) - - this.postMessage({ - type: "loaded", - url - }) - } - - // For future loaded events - document.addEventListener("DOMContentLoaded", sendContentLoadedEvent) - - // If the page is loaded already, send the loaded event right now. - if(document.readyState !== "loading") { - sendContentLoadedEvent() - } + navigator.serviceWorker.addEventListener("message", evt => this.onMessage(evt)) } - postMessage(message: any) { + public postMessage(message: any) { const controller = navigator.serviceWorker.controller if(!controller) { @@ -70,7 +29,7 @@ export default class ServiceWorkerManager { controller.postMessage(JSON.stringify(message)) } - onMessage(evt: MessageEvent) { + private onMessage(evt: MessageEvent) { const message = JSON.parse(evt.data) switch(message.type) { @@ -80,25 +39,6 @@ export default class ServiceWorkerManager { } break - - // case "new content": - // if(message.url.includes("/_/")) { - // // Content reload - // this.arn.contentLoadedActions.then(() => { - // this.arn.reloadContent(true) - // }) - // } else { - // // Full page reload - // this.arn.contentLoadedActions.then(() => { - // this.arn.reloadPage() - // }) - // } - - // break - - // case "offline": - // this.arn.statusMessage.showError("You are viewing an offline version of the site now.") - // break } } } diff --git a/scripts/StatusMessage.ts b/scripts/StatusMessage.ts index cd7d2f3c..9fb60762 100644 --- a/scripts/StatusMessage.ts +++ b/scripts/StatusMessage.ts @@ -1,4 +1,4 @@ -import { delay } from "./Utils" +import delay from "./Utils/delay" export default class StatusMessage { private container: HTMLElement diff --git a/scripts/User.ts b/scripts/User.ts new file mode 100644 index 00000000..dbd522bd --- /dev/null +++ b/scripts/User.ts @@ -0,0 +1,19 @@ +export default class User { + public id: string + private proExpires: string + + public constructor(id: string) { + this.id = id + this.sync() + } + + public IsPro(): boolean { + return new Date() > new Date(this.proExpires) + } + + private async sync() { + const response = await fetch(`/api/user/${this.id}`) + const json = await response.json() + Object.assign(this, json) + } +} diff --git a/scripts/Utils/bytesHumanReadable.ts b/scripts/Utils/bytesHumanReadable.ts index 2e537aa6..2f6c441f 100644 --- a/scripts/Utils/bytesHumanReadable.ts +++ b/scripts/Utils/bytesHumanReadable.ts @@ -1,4 +1,4 @@ -export function bytesHumanReadable(fileSize: number): string { +export default function bytesHumanReadable(fileSize: number): string { let unit = "bytes" if(fileSize >= 1024) { diff --git a/scripts/Utils/delay.ts b/scripts/Utils/delay.ts index 7e411f7c..1679a4db 100644 --- a/scripts/Utils/delay.ts +++ b/scripts/Utils/delay.ts @@ -1,3 +1,3 @@ -export function delay(millis: number, value?: T): Promise { +export default function delay(millis: number, value?: T): Promise { return new Promise(resolve => setTimeout(() => resolve(value), millis)) } diff --git a/scripts/Utils/emptyPixel.ts b/scripts/Utils/emptyPixel.ts new file mode 100644 index 00000000..49b50f59 --- /dev/null +++ b/scripts/Utils/emptyPixel.ts @@ -0,0 +1 @@ +export default "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" diff --git a/scripts/Utils/findAll.ts b/scripts/Utils/findAll.ts index 79b21b79..30453dc2 100644 --- a/scripts/Utils/findAll.ts +++ b/scripts/Utils/findAll.ts @@ -1,15 +1,7 @@ -export function* findAll(className: string): IterableIterator { +export default function* findAll(className: string): IterableIterator { const elements = document.getElementsByClassName(className) - for(let i = 0; i < elements.length; ++i) { - yield elements[i] as HTMLElement - } -} - -export function* findAllInside(className: string, root: HTMLElement): IterableIterator { - const elements = root.getElementsByClassName(className) - - for(let i = 0; i < elements.length; ++i) { - yield elements[i] as HTMLElement + for(const element of elements) { + yield element as HTMLElement } } diff --git a/scripts/Utils/findAllInside.ts b/scripts/Utils/findAllInside.ts new file mode 100644 index 00000000..366b04c9 --- /dev/null +++ b/scripts/Utils/findAllInside.ts @@ -0,0 +1,7 @@ +export default function* findAllInside(className: string, root: HTMLElement): IterableIterator { + const elements = root.getElementsByClassName(className) + + for(const element of elements) { + yield element as HTMLElement + } +} diff --git a/scripts/Utils/hexToHSL.ts b/scripts/Utils/hexToHSL.ts index 4ddabd74..d0e94be8 100644 --- a/scripts/Utils/hexToHSL.ts +++ b/scripts/Utils/hexToHSL.ts @@ -1,4 +1,4 @@ -export function hexToHSL(hex: string) { +export default function hexToHSL(hex: string) { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) if(!result) { @@ -20,7 +20,7 @@ export function hexToHSL(hex: string) { let s = 0 const l = (max + min) / 2 - if(max == min) { + if(max === min) { h = s = 0 } else { const d = max - min diff --git a/scripts/Utils/index.ts b/scripts/Utils/index.ts deleted file mode 100644 index 9184981a..00000000 --- a/scripts/Utils/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./supportsWebP" -export * from "./delay" -export * from "./findAll" -export * from "./hexToHSL" -export * from "./plural" -export * from "./requestIdleCallback" -export * from "./swapElements" -export * from "./uploadWithProgress" -export * from "./bytesHumanReadable" diff --git a/scripts/Utils/plural.ts b/scripts/Utils/plural.ts index 59996c1a..a1884247 100644 --- a/scripts/Utils/plural.ts +++ b/scripts/Utils/plural.ts @@ -2,7 +2,7 @@ const specialized = { "new activity": "new activities" } -export function plural(count: number, singular: string): string { +export default function plural(count: number, singular: string): string { if(count === 1 || count === -1) { return count + " " + singular } diff --git a/scripts/Utils/requestIdleCallback.ts b/scripts/Utils/requestIdleCallback.ts index 7bddd7e0..a2c7e030 100644 --- a/scripts/Utils/requestIdleCallback.ts +++ b/scripts/Utils/requestIdleCallback.ts @@ -1,4 +1,4 @@ -export function requestIdleCallback(func: Function) { +export default function requestIdleCallback(func: Function) { if("requestIdleCallback" in window) { (window["requestIdleCallback"] as Function)(func) } else { diff --git a/scripts/Utils/supportsWebP.ts b/scripts/Utils/supportsWebP.ts index 47c22414..3d1f0a4d 100644 --- a/scripts/Utils/supportsWebP.ts +++ b/scripts/Utils/supportsWebP.ts @@ -1,4 +1,4 @@ -export async function supportsWebP(): Promise { +export default async function supportsWebP(): Promise { if(!window.createImageBitmap) { return false } diff --git a/scripts/Utils/swapElements.ts b/scripts/Utils/swapElements.ts index eb80ad6f..7130c5e2 100644 --- a/scripts/Utils/swapElements.ts +++ b/scripts/Utils/swapElements.ts @@ -1,5 +1,5 @@ // swapElements assumes that both elements have valid parent nodes. -export function swapElements(a: Node, b: Node) { +export default function swapElements(a: Node, b: Node) { const bParent = b.parentNode as Node const bNext = b.nextSibling diff --git a/scripts/Utils/uploadWithProgress.ts b/scripts/Utils/uploadWithProgress.ts index 27e07381..e0ba5e18 100644 --- a/scripts/Utils/uploadWithProgress.ts +++ b/scripts/Utils/uploadWithProgress.ts @@ -1,4 +1,4 @@ -export function uploadWithProgress(url, options: RequestInit, onProgress: ((ev: ProgressEvent) => any) | null): Promise { +export default function uploadWithProgress(url, options: RequestInit, onProgress: ((ev: ProgressEvent) => any) | null): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() diff --git a/scripts/infiniteScroll.ts b/scripts/infiniteScroll.ts new file mode 100644 index 00000000..814a7398 --- /dev/null +++ b/scripts/infiniteScroll.ts @@ -0,0 +1,37 @@ +import Diff from "./Diff" + +let container: HTMLElement +let threshold: number + +export default function infiniteScroll(scrollContainer: HTMLElement, scrollThreshold: number) { + container = scrollContainer + threshold = scrollThreshold + + container.addEventListener("scroll", _ => { + // Wait for mutations to finish before checking if we need infinite scroll to trigger. + if(Diff.mutations.length() > 0) { + Diff.mutations.wait(check) + return + } + + // Otherwise, queue up the check immediately. + // Don't call check() directly to make scrolling as smooth as possible. + Diff.mutations.queue(() => check()) + }) +} + +function check() { + if(container.scrollTop + container.clientHeight >= container.scrollHeight - threshold) { + loadMore() + } +} + +function loadMore() { + const button = document.getElementById("load-more-button") + + if(!button) { + return + } + + button.click() +} diff --git a/scripts/main.ts b/scripts/main.ts index 8528a97c..72b3084f 100644 --- a/scripts/main.ts +++ b/scripts/main.ts @@ -1,7 +1,3 @@ import AnimeNotifier from "./AnimeNotifier" -import Application from "./Application" -const app = new Application() -const arn = new AnimeNotifier(app) - -arn.init() +new AnimeNotifier().init() diff --git a/tslint.json b/tslint.json index 6896fb07..d22e5655 100644 --- a/tslint.json +++ b/tslint.json @@ -12,7 +12,12 @@ "trailing-comma": false, "prefer-const": true, "no-var-keyword": true, - "eofline": true + "eofline": true, + "no-console": false, + "object-literal-sort-keys": false, + "no-string-literal": false, + "max-line-length": false, + "no-string-throw": false }, "rulesDirectory": [] }