549 lines
12 KiB
TypeScript
Raw Normal View History

2017-06-19 14:49:24 +00:00
import { Application } from "./Application"
2017-06-21 16:44:20 +00:00
import { Diff } from "./Diff"
2017-07-07 15:16:40 +00:00
import { displayAiringDate, displayDate } from "./DateView"
2017-07-08 13:40:13 +00:00
import { findAll, delay, canUseWebP } from "./Utils"
2017-07-05 19:06:38 +00:00
import { MutationQueue } from "./MutationQueue"
2017-07-12 18:37:34 +00:00
import { StatusMessage } from "./StatusMessage"
2017-06-30 15:51:17 +00:00
import * as actions from "./Actions"
2017-06-29 06:32:46 +00:00
2017-06-19 14:49:24 +00:00
export class AnimeNotifier {
app: Application
2017-06-30 15:51:17 +00:00
user: HTMLElement
2017-07-06 20:08:49 +00:00
title: string
2017-07-08 13:40:13 +00:00
webpEnabled: boolean
2017-07-12 18:37:34 +00:00
statusMessage: StatusMessage
2017-07-05 19:06:38 +00:00
visibilityObserver: IntersectionObserver
imageFound: MutationQueue
imageNotFound: MutationQueue
unmount: MutationQueue
2017-06-19 14:49:24 +00:00
constructor(app: Application) {
this.app = app
2017-06-30 15:51:17 +00:00
this.user = null
2017-07-06 20:08:49 +00:00
this.title = "Anime Notifier"
2017-07-05 19:06:38 +00:00
this.imageFound = new MutationQueue(elem => elem.classList.add("image-found"))
this.imageNotFound = new MutationQueue(elem => elem.classList.add("image-not-found"))
this.unmount = new MutationQueue(elem => elem.classList.remove("mounted"))
if("IntersectionObserver" in window) {
// Enable lazy load
this.visibilityObserver = new IntersectionObserver(
entries => {
for(let entry of entries) {
if(entry.intersectionRatio > 0) {
entry.target["became visible"]()
this.visibilityObserver.unobserve(entry.target)
}
2017-06-24 14:17:38 +00:00
}
},
{}
)
} else {
// Disable lazy load feature
this.visibilityObserver = {
disconnect: () => {},
observe: (elem: HTMLElement) => {
elem["became visible"]()
},
unobserve: (elem: HTMLElement) => {}
} as IntersectionObserver
}
2017-06-19 14:49:24 +00:00
}
2017-06-30 15:51:17 +00:00
init() {
document.addEventListener("readystatechange", this.onReadyStateChange.bind(this))
document.addEventListener("DOMContentLoaded", this.onContentLoaded.bind(this))
document.addEventListener("keydown", this.onKeyDown.bind(this), false)
window.addEventListener("popstate", this.onPopState.bind(this))
2017-07-03 15:21:00 +00:00
this.requestIdleCallback(this.onIdle.bind(this))
}
requestIdleCallback(func: Function) {
2017-06-30 15:51:17 +00:00
if("requestIdleCallback" in window) {
2017-07-03 15:21:00 +00:00
window["requestIdleCallback"](func)
2017-06-30 15:51:17 +00:00
} else {
2017-07-03 15:21:00 +00:00
func()
2017-06-30 15:51:17 +00:00
}
}
2017-06-19 15:45:27 +00:00
onReadyStateChange() {
if(document.readyState !== "interactive") {
return
}
this.run()
}
2017-06-19 14:49:24 +00:00
run() {
2017-07-03 23:20:27 +00:00
// Add "osx" class on macs so we can set a proper font-size
if(navigator.platform.includes("Mac")) {
2017-07-03 23:25:20 +00:00
document.documentElement.classList.add("osx")
2017-07-03 23:20:27 +00:00
}
2017-07-08 13:40:13 +00:00
// Check for WebP support
this.webpEnabled = canUseWebP()
2017-07-03 23:20:27 +00:00
// Initiate the elements we need
2017-06-30 15:51:17 +00:00
this.user = this.app.find("user")
2017-06-19 14:49:24 +00:00
this.app.content = this.app.find("content")
this.app.loading = this.app.find("loading")
2017-07-03 23:20:27 +00:00
2017-07-12 18:37:34 +00:00
// Status message
this.statusMessage = new StatusMessage(
this.app.find("status-message"),
this.app.find("status-message-text")
)
2017-07-13 15:56:14 +00:00
// Let"s start
2017-06-19 14:49:24 +00:00
this.app.run()
2017-07-13 22:11:25 +00:00
// Service worker
this.registerServiceWorker()
2017-06-19 14:49:24 +00:00
}
2017-06-19 15:45:27 +00:00
2017-06-24 14:17:38 +00:00
onContentLoaded() {
2017-06-30 15:51:17 +00:00
// Stop watching all the objects from the previous page.
2017-06-24 14:17:38 +00:00
this.visibilityObserver.disconnect()
2017-06-30 15:51:17 +00:00
Promise.resolve().then(() => this.mountMountables()),
Promise.resolve().then(() => this.lazyLoadImages()),
Promise.resolve().then(() => this.displayLocalDates()),
Promise.resolve().then(() => this.setSelectBoxValue()),
2017-06-29 13:55:04 +00:00
Promise.resolve().then(() => this.assignActions())
2017-07-06 20:08:49 +00:00
let headers = document.getElementsByTagName("h1")
if(this.app.currentPath === "/" || headers.length === 0) {
if(document.title !== this.title) {
document.title = this.title
}
} else {
document.title = headers[0].innerText
}
2017-06-29 13:55:04 +00:00
}
2017-06-30 15:51:17 +00:00
onIdle() {
2017-07-13 15:56:14 +00:00
this.pushAnalytics()
}
registerServiceWorker() {
2017-07-13 22:11:25 +00:00
if(!("serviceWorker" in navigator)) {
return
}
2017-07-13 15:56:14 +00:00
navigator.serviceWorker.register("service-worker", {
2017-07-13 18:45:16 +00:00
scope: "./"
2017-07-13 22:11:25 +00:00
}).then(registration => {
registration.update()
2017-07-13 15:56:14 +00:00
})
2017-07-13 22:11:25 +00:00
navigator.serviceWorker.onmessage = evt => {
this.onServiceWorkerMessage(evt)
}
}
onServiceWorkerMessage(evt: ServiceWorkerMessageEvent) {
let message = JSON.parse(evt.data)
console.log(message.url, this.app.eTag, message.eTag)
switch(message.type) {
case "content changed":
// If we don't have an etag it means it was a full page refresh.
// In this case we don't need to reload anything.
if(!this.app.eTag) {
this.app.eTag = message.eTag
return
}
if(this.app.eTag !== message.eTag) {
if(message.url.includes("/_/")) {
// Content reload
this.reloadContent()
} else {
// Full page reload
this.reloadPage()
}
}
break
}
2017-07-13 15:56:14 +00:00
}
pushAnalytics() {
2017-06-30 15:51:17 +00:00
if(!this.user) {
return
}
let 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
}
}
2017-07-05 21:48:29 +00:00
fetch("/dark-flame-master", {
2017-06-30 15:51:17 +00:00
method: "POST",
credentials: "same-origin",
body: JSON.stringify(analytics)
})
}
2017-06-29 13:55:04 +00:00
setSelectBoxValue() {
for(let element of document.getElementsByTagName("select")) {
element.value = element.getAttribute("value")
}
2017-06-29 06:32:46 +00:00
}
displayLocalDates() {
const now = new Date()
2017-07-07 15:16:40 +00:00
for(let element of findAll("utc-airing-date")) {
displayAiringDate(element, now)
}
2017-06-29 06:32:46 +00:00
for(let element of findAll("utc-date")) {
2017-07-07 15:16:40 +00:00
displayDate(element, now)
2017-06-29 06:32:46 +00:00
}
2017-06-24 14:17:38 +00:00
}
2017-06-21 16:44:20 +00:00
reloadContent() {
2017-07-13 22:11:25 +00:00
let headers = new Headers()
headers.append("X-Reload", "true")
2017-07-13 23:07:49 +00:00
let path = this.app.currentPath
return fetch("/_" + path, {
2017-07-13 22:11:25 +00:00
credentials: "same-origin",
headers
})
.then(response => {
2017-07-13 23:07:49 +00:00
if(this.app.currentPath !== path) {
return Promise.reject("old request")
}
2017-07-13 22:11:25 +00:00
this.app.eTag = response.headers.get("ETag")
2017-07-13 23:07:49 +00:00
return Promise.resolve(response)
2017-06-21 16:44:20 +00:00
})
.then(response => response.text())
.then(html => Diff.innerHTML(this.app.content, html))
2017-06-24 00:10:04 +00:00
.then(() => this.app.emit("DOMContentLoaded"))
2017-06-21 16:44:20 +00:00
}
2017-07-13 22:11:25 +00:00
reloadPage() {
let headers = new Headers()
headers.append("X-Reload", "true")
return fetch(this.app.currentPath, {
credentials: "same-origin",
headers
})
.then(response => {
this.app.eTag = response.headers.get("ETag")
return response
})
.then(response => response.text())
.then(html => Diff.innerHTML(document.body, html))
.then(() => this.app.emit("DOMContentLoaded"))
}
2017-06-20 10:41:26 +00:00
loading(isLoading: boolean) {
if(isLoading) {
2017-07-01 11:35:21 +00:00
document.body.style.cursor = "progress"
2017-06-20 10:41:26 +00:00
this.app.loading.classList.remove(this.app.fadeOutClass)
} else {
2017-07-01 11:35:21 +00:00
document.body.style.cursor = "auto"
2017-06-20 10:41:26 +00:00
this.app.loading.classList.add(this.app.fadeOutClass)
}
}
2017-06-21 12:00:52 +00:00
2017-06-26 01:57:29 +00:00
assignActions() {
2017-06-21 13:29:06 +00:00
for(let element of findAll("action")) {
2017-07-08 21:27:24 +00:00
let actionTrigger = element.dataset.trigger
2017-06-20 10:41:26 +00:00
let actionName = element.dataset.action
2017-07-08 21:27:24 +00:00
let oldAction = element["action assigned"]
if(oldAction) {
if(oldAction.trigger === actionTrigger && oldAction.action === actionName) {
continue
}
element.removeEventListener(oldAction.trigger, oldAction.handler)
}
let actionHandler = e => {
2017-06-20 20:54:45 +00:00
actions[actionName](this, element, e)
2017-06-26 01:57:29 +00:00
e.stopPropagation()
e.preventDefault()
2017-07-08 21:27:24 +00:00
}
element.addEventListener(actionTrigger, actionHandler)
2017-06-20 20:54:45 +00:00
2017-06-24 15:38:10 +00:00
// 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.
2017-07-08 21:27:24 +00:00
element["action assigned"] = {
trigger: actionTrigger,
action: actionName,
handler: actionHandler
}
2017-06-20 10:41:26 +00:00
}
2017-06-19 15:45:27 +00:00
}
2017-06-24 14:17:38 +00:00
lazyLoadImages() {
2017-06-24 15:38:10 +00:00
for(let element of findAll("lazy")) {
2017-06-24 14:17:38 +00:00
this.lazyLoadImage(element as HTMLImageElement)
}
}
lazyLoadImage(img: HTMLImageElement) {
// Once the image becomes visible, load it
img["became visible"] = () => {
2017-07-08 13:40:13 +00:00
// Replace URL with WebP if supported
if(this.webpEnabled && img.dataset.webp) {
let dot = img.dataset.src.lastIndexOf(".")
img.src = img.dataset.src.substring(0, dot) + ".webp"
} else {
img.src = img.dataset.src
}
2017-06-19 15:45:27 +00:00
if(img.naturalWidth === 0) {
2017-07-05 19:06:38 +00:00
img.onload = () => {
this.imageFound.queue(img)
2017-06-19 15:45:27 +00:00
}
2017-07-05 19:06:38 +00:00
img.onerror = () => {
this.imageNotFound.queue(img)
2017-06-19 15:45:27 +00:00
}
} else {
2017-07-05 19:06:38 +00:00
this.imageFound.queue(img)
2017-06-19 15:45:27 +00:00
}
}
2017-06-24 14:17:38 +00:00
this.visibilityObserver.observe(img)
2017-06-19 15:45:27 +00:00
}
2017-06-20 12:16:23 +00:00
2017-06-26 01:57:29 +00:00
mountMountables() {
this.modifyDelayed("mountable", element => element.classList.add("mounted"))
}
unmountMountables() {
2017-06-26 10:32:07 +00:00
for(let element of findAll("mountable")) {
2017-07-03 15:21:00 +00:00
if(element.classList.contains("never-unmount")) {
continue
}
2017-07-05 19:06:38 +00:00
this.unmount.queue(element)
2017-06-26 01:57:29 +00:00
}
}
modifyDelayed(className: string, func: (element: HTMLElement) => void) {
2017-06-20 18:13:04 +00:00
const delay = 20
2017-07-06 01:24:56 +00:00
const maxDelay = 1000
2017-06-20 18:13:04 +00:00
let time = 0
2017-07-05 13:29:18 +00:00
let start = Date.now()
2017-07-06 01:24:56 +00:00
let maxTime = start + maxDelay
2017-07-05 13:00:58 +00:00
let collection = document.getElementsByClassName(className)
2017-07-05 13:29:18 +00:00
let mutations = []
let mountableTypes = {
general: start
}
2017-06-20 18:13:04 +00:00
2017-07-05 13:00:58 +00:00
for(let i = 0; i < collection.length; i++) {
let element = collection.item(i) as HTMLElement
2017-07-02 15:51:17 +00:00
let type = element.dataset.mountableType || "general"
if(type in mountableTypes) {
2017-07-02 16:05:12 +00:00
time = mountableTypes[type] += delay
2017-07-02 15:51:17 +00:00
} else {
2017-07-05 13:29:18 +00:00
time = mountableTypes[type] = start
2017-07-02 15:51:17 +00:00
}
2017-06-20 18:13:04 +00:00
2017-07-06 01:24:56 +00:00
if(time > maxTime) {
time = maxTime
}
2017-07-05 13:29:18 +00:00
mutations.push({
element,
time
})
}
let mutationIndex = 0
let updateBatch = () => {
let now = Date.now()
for(; mutationIndex < mutations.length; mutationIndex++) {
let mutation = mutations[mutationIndex]
if(mutation.time > now) {
break
}
func(mutation.element)
}
if(mutationIndex < mutations.length) {
window.requestAnimationFrame(updateBatch)
2017-06-20 18:13:04 +00:00
}
}
2017-07-05 13:29:18 +00:00
window.requestAnimationFrame(updateBatch)
2017-06-20 18:13:04 +00:00
}
2017-06-27 02:15:52 +00:00
diff(url: string) {
2017-07-03 15:21:00 +00:00
if(url == this.app.currentPath) {
2017-07-06 01:24:56 +00:00
return Promise.reject(null)
2017-07-03 15:21:00 +00:00
}
let request = fetch("/_" + url, {
credentials: "same-origin"
})
.then(response => response.text())
2017-06-26 17:03:48 +00:00
history.pushState(url, null, url)
this.app.currentPath = url
this.app.markActiveLinks()
this.unmountMountables()
this.loading(true)
2017-06-26 17:03:48 +00:00
2017-06-29 14:49:26 +00:00
// Delay by transition-speed
return delay(300).then(() => {
2017-07-06 01:24:56 +00:00
return request
2017-06-26 17:03:48 +00:00
.then(html => this.app.setContent(html, true))
.then(() => this.app.markActiveLinks())
.then(() => this.app.emit("DOMContentLoaded"))
.then(() => this.loading(false))
.catch(console.error)
})
}
2017-06-27 02:15:52 +00:00
post(url, obj) {
return fetch(url, {
method: "POST",
body: JSON.stringify(obj),
credentials: "same-origin"
})
.then(response => response.text())
.then(body => {
if(body !== "ok") {
throw body
}
})
}
2017-07-06 14:54:10 +00:00
scrollTo(target: HTMLElement) {
const duration = 250.0
const steps = 60
const interval = duration / steps
const fullSin = Math.PI / 2
const contentPadding = 24
let scrollHandle: number
let oldScroll = this.app.content.parentElement.scrollTop
let newScroll = 0
let finalScroll = Math.max(target.offsetTop - contentPadding, 0)
let scrollDistance = finalScroll - oldScroll
let timeStart = Date.now()
let timeEnd = timeStart + duration
let scroll = () => {
let time = Date.now()
let progress = (time - timeStart) / duration
if(progress > 1.0) {
progress = 1.0
}
newScroll = oldScroll + scrollDistance * Math.sin(progress * fullSin)
this.app.content.parentElement.scrollTop = newScroll
if(time < timeEnd && newScroll != finalScroll) {
window.requestAnimationFrame(scroll)
}
}
window.requestAnimationFrame(scroll)
}
findAPIEndpoint(element: HTMLElement) {
let apiObject: HTMLElement
let parent = element
while(parent = parent.parentElement) {
if(parent.dataset.api !== undefined) {
apiObject = parent
break
}
}
if(!apiObject) {
throw "API object not found"
}
return apiObject.dataset.api
}
2017-06-21 12:00:52 +00:00
onPopState(e: PopStateEvent) {
if(e.state) {
this.app.load(e.state, {
addToHistory: false
})
} else if(this.app.currentPath !== this.app.originalPath) {
this.app.load(this.app.originalPath, {
addToHistory: false
})
}
}
onKeyDown(e: KeyboardEvent) {
2017-06-30 21:52:42 +00:00
let activeElement = document.activeElement
2017-06-24 14:41:22 +00:00
// Ignore hotkeys on input elements
2017-06-30 21:52:42 +00:00
switch(activeElement.tagName) {
2017-06-24 14:41:22 +00:00
case "INPUT":
case "TEXTAREA":
return
}
2017-06-30 21:52:42 +00:00
// Disallow Enter key in contenteditables
if(activeElement.getAttribute("contenteditable") === "true" && e.keyCode == 13) {
if("blur" in activeElement) {
activeElement["blur"]()
}
e.preventDefault()
e.stopPropagation()
return
}
2017-06-24 14:31:54 +00:00
// F = Search
2017-07-04 14:13:20 +00:00
if(e.keyCode == 70 && !e.ctrlKey) {
2017-06-21 12:00:52 +00:00
let search = this.app.find("search") as HTMLInputElement
search.focus()
search.select()
e.preventDefault()
e.stopPropagation()
2017-06-30 21:52:42 +00:00
return
2017-06-21 12:00:52 +00:00
}
}
2017-06-19 14:49:24 +00:00
}