diff --git a/main.go b/main.go index b70b1e1f..74fa4eb8 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ import ( "github.com/animenotifier/notify.moe/pages/music" "github.com/animenotifier/notify.moe/pages/newsoundtrack" "github.com/animenotifier/notify.moe/pages/newthread" + "github.com/animenotifier/notify.moe/pages/notifications" "github.com/animenotifier/notify.moe/pages/posts" "github.com/animenotifier/notify.moe/pages/profile" "github.com/animenotifier/notify.moe/pages/search" @@ -126,6 +127,9 @@ func configure(app *aero.Application) *aero.Application { // Browser extension app.Ajax("/extension/embed", embed.Get) + // API + app.Get("/api/test/notification", notifications.Test) + // Middleware app.Use(middleware.Log()) app.Use(middleware.Session()) diff --git a/pages/notifications/notifications.go b/pages/notifications/notifications.go new file mode 100644 index 00000000..3f3d6c64 --- /dev/null +++ b/pages/notifications/notifications.go @@ -0,0 +1,28 @@ +package notifications + +import ( + "fmt" + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/utils" +) + +// Test ... +func Test(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + for _, sub := range user.PushSubscriptions().Items { + err := sub.SendNotification("Yay, it works!") + + if err != nil { + fmt.Println(err) + } + } + + return "ok" +} diff --git a/pages/settings/settings.pixy b/pages/settings/settings.pixy index 34d69748..b96a30cd 100644 --- a/pages/settings/settings.pixy +++ b/pages/settings/settings.pixy @@ -21,6 +21,29 @@ component Settings(user *arn.User) InputText("Accounts.Osu.Nick", user.Accounts.Osu.Nick, "Osu", "Your username on osu.ppy.sh") //- InputText("Accounts.AnimePlanet.Nick", user.Accounts.AnimePlanet.Nick, "AnimePlanet", "Your username on anime-planet.com") + .widget.mountable + h3.widget-title + Icon("bell") + span Notifications + + .widget-input + label Enable: + button.action(data-action="enableNotifications", data-trigger="click") + Icon("toggle-on") + span Enable notifications + + .widget-input + label Disable: + button.action(data-action="disableNotifications", data-trigger="click") + Icon("toggle-off") + span Disable notifications + + .widget-input + label Test: + button.action(data-action="testNotification", data-trigger="click") + Icon("paper-plane") + span Send test notification + .widget.mountable h3.widget-title Icon("user-plus") @@ -49,17 +72,6 @@ component Settings(user *arn.User) Icon("circle-o") span Not connected - .widget.mountable - h3.widget-title - Icon("puzzle-piece") - span Extensions - - .widget-input - label Chrome Extension: - button.action(data-action="installExtension", data-trigger="click") - Icon("chrome") - span Get the Chrome Extension - .widget.mountable h3.widget-title Icon("download") @@ -78,6 +90,17 @@ component Settings(user *arn.User) Icon("upload") span Export anime list as JSON + .widget.mountable + h3.widget-title + Icon("puzzle-piece") + span Extensions + + .widget-input + label Chrome Extension: + button.action(data-action="installExtension", data-trigger="click") + Icon("chrome") + span Get the Chrome Extension + //- .widget.mountable(data-api="/api/settings/" + user.ID) //- h3.widget-title //- Icon("cogs") diff --git a/patches/add-push-subs/add-push-subs.go b/patches/add-push-subs/add-push-subs.go new file mode 100644 index 00000000..932487a2 --- /dev/null +++ b/patches/add-push-subs/add-push-subs.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Adding push subscriptions to users who don't have one") + + // Get a stream of all users + allUsers, err := arn.StreamUsers() + + if err != nil { + panic(err) + } + + // Iterate over the stream + for user := range allUsers { + exists, err := arn.DB.Exists("PushSubscriptions", user.ID) + + if err == nil && !exists { + fmt.Println(user.Nick) + + err := arn.DB.Set("PushSubscriptions", user.ID, &arn.PushSubscriptions{ + UserID: user.ID, + Items: make([]*arn.PushSubscription, 0), + }) + + if err != nil { + color.Red(err.Error()) + } + } + } + + color.Green("Finished.") +} diff --git a/scripts/Actions.ts b/scripts/Actions.ts index bd5672d2..2b38f95e 100644 --- a/scripts/Actions.ts +++ b/scripts/Actions.ts @@ -214,6 +214,23 @@ export function search(arn: AnimeNotifier, search: HTMLInputElement, e: Keyboard arn.diff("/search/" + term) } +// Enable notifications +export function enableNotifications(arn: AnimeNotifier, button: HTMLElement) { + arn.pushManager.subscribe(arn.user.dataset.id) +} + +// Disable notifications +export function disableNotifications(arn: AnimeNotifier, button: HTMLElement) { + arn.pushManager.unsubscribe(arn.user.dataset.id) +} + +// Test notification +export function testNotification(arn: AnimeNotifier) { + fetch("/api/test/notification", { + credentials: "same-origin" + }) +} + // Add anime to collection export function addAnimeToCollection(arn: AnimeNotifier, button: HTMLElement) { button.innerText = "Adding..." diff --git a/scripts/AnimeNotifier.ts b/scripts/AnimeNotifier.ts index f3964900..0c6db38c 100644 --- a/scripts/AnimeNotifier.ts +++ b/scripts/AnimeNotifier.ts @@ -1,10 +1,11 @@ -import { Application } from "./Application" -import { Diff } from "./Diff" +import * as actions from "./Actions" import { displayAiringDate, displayDate } from "./DateView" import { findAll, delay, canUseWebP } from "./Utils" +import { Application } from "./Application" +import { Diff } from "./Diff" import { MutationQueue } from "./MutationQueue" import { StatusMessage } from "./StatusMessage" -import * as actions from "./Actions" +import { PushManager } from "./PushManager" export class AnimeNotifier { app: Application @@ -14,6 +15,7 @@ export class AnimeNotifier { contentLoadedActions: Promise statusMessage: StatusMessage visibilityObserver: IntersectionObserver + pushManager: PushManager imageFound: MutationQueue imageNotFound: MutationQueue @@ -104,6 +106,9 @@ export class AnimeNotifier { // Service worker this.registerServiceWorker() + + // Push manager + this.pushManager = new PushManager() } onContentLoaded() { diff --git a/scripts/PushManager.ts b/scripts/PushManager.ts new file mode 100644 index 00000000..671f6c8b --- /dev/null +++ b/scripts/PushManager.ts @@ -0,0 +1,105 @@ +export class PushManager { + pushSupported: boolean + + constructor() { + this.pushSupported = ("serviceWorker" in navigator) && ("PushManager" in window) + } + + async subscribe(userId: string) { + if(!this.pushSupported) { + return + } + + let registration = await navigator.serviceWorker.ready + let subscription = await registration.pushManager.getSubscription() + + if(!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array("BAwPKVCWQ2_nc7SIGltYfWZhMpW54BSkbwelpa8eYMbqSitmCAGm3xRBdRiq1Wt-hUsE7x59GCcaJxqQtF2hZPw") + }) + + this.subscribeOnServer(subscription, userId) + } else { + console.log("Using existing subscription", subscription) + } + } + + async unsubscribe(userId: string) { + if(!this.pushSupported) { + return + } + + let registration = await navigator.serviceWorker.ready + let subscription = await registration.pushManager.getSubscription() + + if(!subscription) { + console.error("Subscription does not exist") + return + } + + await subscription.unsubscribe() + + this.unsubscribeOnServer(subscription, userId) + } + + subscribeOnServer(subscription: PushSubscription, userId: string) { + console.log("Send subscription to server...") + + let rawKey = subscription.getKey("p256dh") + let key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : "" + + let rawSecret = subscription.getKey("auth") + let secret = rawSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawSecret))) : "" + + let endpoint = subscription.endpoint + + let pushSubscription = { + endpoint, + p256dh: key, + auth: secret, + platform: navigator.platform, + screen: { + width: window.screen.width, + height: window.screen.height + } + } + + return fetch("/api/pushsubscriptions/" + userId + "/add", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(pushSubscription) + }) + } + + unsubscribeOnServer(subscription: PushSubscription, userId: string) { + console.log("Send unsubscription to server...") + console.log(subscription) + + let pushSubscription = { + endpoint: subscription.endpoint + } + + return fetch("/api/pushsubscriptions/" + userId + "/remove", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(pushSubscription) + }) + } +} + +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding) + .replace(/\-/g, "+") + .replace(/_/g, "/") + + const rawData = window.atob(base64) + const outputArray = new Uint8Array(rawData.length) + + for(let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + + return outputArray +} \ No newline at end of file diff --git a/sw/index.d.ts b/sw/index.d.ts new file mode 100644 index 00000000..2278ea56 --- /dev/null +++ b/sw/index.d.ts @@ -0,0 +1,4 @@ +type NotificationEvent = any; +type InstallEvent = any; +type FetchEvent = any; +type PushEvent = any; \ No newline at end of file diff --git a/sw/service-worker.ts b/sw/service-worker.ts index 0a918432..f888ab26 100644 --- a/sw/service-worker.ts +++ b/sw/service-worker.ts @@ -4,7 +4,7 @@ const CACHE = "v-1" const RELOADS = new Map>() const ETAGS = new Map() -self.addEventListener("install", (evt: any) => { +self.addEventListener("install", (evt: InstallEvent) => { console.log("Service worker install") evt.waitUntil( @@ -37,7 +37,7 @@ self.addEventListener("message", (evt: any) => { evt.waitUntil( refresh.then((response: Response) => { // If the fresh copy was used to serve the request instead of the cache, - // we don't need to tell the client to do a refresh. + // we don"t need to tell the client to do a refresh. if(response.bodyUsed) { return } @@ -60,9 +60,10 @@ self.addEventListener("message", (evt: any) => { ) }) -self.addEventListener("fetch", async (evt: any) => { +self.addEventListener("fetch", async (evt: FetchEvent) => { let request = evt.request let isAuth = request.url.includes("/auth/") || request.url.includes("/logout") + let ignoreCache = request.url.includes("/api/") || request.url.includes("chrome-extension") // Delete existing cache on authentication if(isAuth) { @@ -70,7 +71,7 @@ self.addEventListener("fetch", async (evt: any) => { } // Do not use cache in some cases - if(request.method !== "GET" || isAuth || request.url.includes("chrome-extension")) { + if(request.method !== "GET" || isAuth || ignoreCache) { return evt.waitUntil(evt.respondWith(fetch(request))) } @@ -109,6 +110,38 @@ self.addEventListener("fetch", async (evt: any) => { return evt.waitUntil(evt.respondWith(networkOrCache)) }) +self.addEventListener("push", (evt: PushEvent) => { + var payload = evt.data ? evt.data.text() : "no payload" + + evt.waitUntil( + (self as any).registration.showNotification("beta.notify.moe Service Worker", { + body: payload + }) + ) +}) + +self.addEventListener("pushsubscriptionchange", (evt: any) => { + console.log("pushsubscriptionchange", evt) +}) + +self.addEventListener("notificationclick", (evt: NotificationEvent) => { + console.log(evt) + + evt.notification.close() + + evt.waitUntil( + (self as any).clients.matchAll().then(function(clientList) { + // If there is at least one client, focus it. + if(clientList.length > 0) { + return clientList[0].focus() + } + + // Otherwise open a new window + return (self as any).clients.openWindow("https://notify.moe") + }) + ) +}) + function installCache() { return caches.open(CACHE).then(cache => { return cache.addAll([