Push notifications

This commit is contained in:
Eduard Urbach 2017-07-14 23:50:34 +02:00
parent 2e504548c4
commit 92a540e024
9 changed files with 276 additions and 18 deletions

View File

@ -31,6 +31,7 @@ import (
"github.com/animenotifier/notify.moe/pages/music" "github.com/animenotifier/notify.moe/pages/music"
"github.com/animenotifier/notify.moe/pages/newsoundtrack" "github.com/animenotifier/notify.moe/pages/newsoundtrack"
"github.com/animenotifier/notify.moe/pages/newthread" "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/posts"
"github.com/animenotifier/notify.moe/pages/profile" "github.com/animenotifier/notify.moe/pages/profile"
"github.com/animenotifier/notify.moe/pages/search" "github.com/animenotifier/notify.moe/pages/search"
@ -126,6 +127,9 @@ func configure(app *aero.Application) *aero.Application {
// Browser extension // Browser extension
app.Ajax("/extension/embed", embed.Get) app.Ajax("/extension/embed", embed.Get)
// API
app.Get("/api/test/notification", notifications.Test)
// Middleware // Middleware
app.Use(middleware.Log()) app.Use(middleware.Log())
app.Use(middleware.Session()) app.Use(middleware.Session())

View File

@ -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"
}

View File

@ -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.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") //- 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 .widget.mountable
h3.widget-title h3.widget-title
Icon("user-plus") Icon("user-plus")
@ -49,17 +72,6 @@ component Settings(user *arn.User)
Icon("circle-o") Icon("circle-o")
span Not connected 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 .widget.mountable
h3.widget-title h3.widget-title
Icon("download") Icon("download")
@ -78,6 +90,17 @@ component Settings(user *arn.User)
Icon("upload") Icon("upload")
span Export anime list as JSON 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) //- .widget.mountable(data-api="/api/settings/" + user.ID)
//- h3.widget-title //- h3.widget-title
//- Icon("cogs") //- Icon("cogs")

View File

@ -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.")
}

View File

@ -214,6 +214,23 @@ export function search(arn: AnimeNotifier, search: HTMLInputElement, e: Keyboard
arn.diff("/search/" + term) 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 // Add anime to collection
export function addAnimeToCollection(arn: AnimeNotifier, button: HTMLElement) { export function addAnimeToCollection(arn: AnimeNotifier, button: HTMLElement) {
button.innerText = "Adding..." button.innerText = "Adding..."

View File

@ -1,10 +1,11 @@
import { Application } from "./Application" import * as actions from "./Actions"
import { Diff } from "./Diff"
import { displayAiringDate, displayDate } from "./DateView" import { displayAiringDate, displayDate } from "./DateView"
import { findAll, delay, canUseWebP } from "./Utils" import { findAll, delay, canUseWebP } from "./Utils"
import { Application } from "./Application"
import { Diff } from "./Diff"
import { MutationQueue } from "./MutationQueue" import { MutationQueue } from "./MutationQueue"
import { StatusMessage } from "./StatusMessage" import { StatusMessage } from "./StatusMessage"
import * as actions from "./Actions" import { PushManager } from "./PushManager"
export class AnimeNotifier { export class AnimeNotifier {
app: Application app: Application
@ -14,6 +15,7 @@ export class AnimeNotifier {
contentLoadedActions: Promise<any> contentLoadedActions: Promise<any>
statusMessage: StatusMessage statusMessage: StatusMessage
visibilityObserver: IntersectionObserver visibilityObserver: IntersectionObserver
pushManager: PushManager
imageFound: MutationQueue imageFound: MutationQueue
imageNotFound: MutationQueue imageNotFound: MutationQueue
@ -104,6 +106,9 @@ export class AnimeNotifier {
// Service worker // Service worker
this.registerServiceWorker() this.registerServiceWorker()
// Push manager
this.pushManager = new PushManager()
} }
onContentLoaded() { onContentLoaded() {

105
scripts/PushManager.ts Normal file
View File

@ -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
}

4
sw/index.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
type NotificationEvent = any;
type InstallEvent = any;
type FetchEvent = any;
type PushEvent = any;

View File

@ -4,7 +4,7 @@ const CACHE = "v-1"
const RELOADS = new Map<string, Promise<Response>>() const RELOADS = new Map<string, Promise<Response>>()
const ETAGS = new Map<string, string>() const ETAGS = new Map<string, string>()
self.addEventListener("install", (evt: any) => { self.addEventListener("install", (evt: InstallEvent) => {
console.log("Service worker install") console.log("Service worker install")
evt.waitUntil( evt.waitUntil(
@ -37,7 +37,7 @@ self.addEventListener("message", (evt: any) => {
evt.waitUntil( evt.waitUntil(
refresh.then((response: Response) => { refresh.then((response: Response) => {
// If the fresh copy was used to serve the request instead of the cache, // 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) { if(response.bodyUsed) {
return 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 request = evt.request
let isAuth = request.url.includes("/auth/") || request.url.includes("/logout") 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 // Delete existing cache on authentication
if(isAuth) { if(isAuth) {
@ -70,7 +71,7 @@ self.addEventListener("fetch", async (evt: any) => {
} }
// Do not use cache in some cases // 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))) return evt.waitUntil(evt.respondWith(fetch(request)))
} }
@ -109,6 +110,38 @@ self.addEventListener("fetch", async (evt: any) => {
return evt.waitUntil(evt.respondWith(networkOrCache)) 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() { function installCache() {
return caches.open(CACHE).then(cache => { return caches.open(CACHE).then(cache => {
return cache.addAll([ return cache.addAll([