Skip to content

Commit cd32115

Browse files
committed
feat: web push
Signed-off-by: Innei <[email protected]>
1 parent d1975c2 commit cd32115

File tree

8 files changed

+286
-3
lines changed

8 files changed

+286
-3
lines changed

apps/renderer/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"use-sync-external-store": "1.2.2",
110110
"usehooks-ts": "3.1.0",
111111
"vfile": "6.0.3",
112+
"web-push": "3.6.7",
112113
"zod": "3.23.8",
113114
"zustand": "5.0.1"
114115
},

apps/renderer/src/main.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@ import ReactDOM from "react-dom/client"
1010
import { RouterProvider } from "react-router/dom"
1111

1212
import { setAppIsReady } from "./atoms/app"
13-
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT } from "./constants"
13+
import { ElECTRON_CUSTOM_TITLEBAR_HEIGHT, isWebBuild } from "./constants"
1414
import { initializeApp } from "./initialize"
1515
import { registerAppGlobalShortcuts } from "./initialize/global-shortcuts"
16+
import { registerWebPushNotifications } from "./push-notification"
1617
import { router } from "./router"
1718

1819
initializeApp().finally(() => {
20+
import("./push-notification").then(({ registerWebPushNotifications }) => {
21+
registerWebPushNotifications()
22+
})
23+
if (navigator.serviceWorker && isWebBuild) {
24+
registerWebPushNotifications()
25+
}
1926
setAppIsReady(true)
2027
})
2128

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { env } from "@follow/shared/env"
2+
import { initializeApp } from "firebase/app"
3+
import { getMessaging, getToken } from "firebase/messaging"
4+
5+
import { apiClient } from "./lib/api-fetch"
6+
import { router } from "./router"
7+
8+
const firebaseConfig = env.VITE_FIREBASE_CONFIG ? JSON.parse(env.VITE_FIREBASE_CONFIG) : null
9+
10+
export async function registerWebPushNotifications() {
11+
if (!firebaseConfig) {
12+
return
13+
}
14+
try {
15+
const existingRegistration = await navigator.serviceWorker.getRegistration()
16+
let registration = existingRegistration
17+
18+
if (!registration) {
19+
registration = await navigator.serviceWorker.register("/sw.js", {
20+
scope: "/",
21+
})
22+
}
23+
24+
await navigator.serviceWorker.ready
25+
26+
const app = initializeApp(firebaseConfig)
27+
const messaging = getMessaging(app)
28+
29+
const permission = await Notification.requestPermission()
30+
if (permission !== "granted") {
31+
throw new Error("Notification permission denied")
32+
}
33+
34+
// get FCM token
35+
const token = await getToken(messaging, {
36+
serviceWorkerRegistration: registration,
37+
})
38+
39+
await apiClient.messaging.$post({
40+
json: {
41+
token,
42+
channel: "desktop",
43+
},
44+
})
45+
46+
registerPushNotificationPostMessage()
47+
48+
return token
49+
} catch (error) {
50+
if (error instanceof Error) {
51+
throw new TypeError(`Failed to register push notifications: ${error.message}`)
52+
}
53+
throw error
54+
}
55+
}
56+
57+
interface NavigateEntryMessage {
58+
type: "NOTIFICATION_CLICK"
59+
action: "NAVIGATE_ENTRY"
60+
data: {
61+
feedId: string
62+
entryId: string
63+
view: number
64+
url: string
65+
}
66+
}
67+
68+
type ServiceWorkerMessage = NavigateEntryMessage
69+
70+
const registerPushNotificationPostMessage = () => {
71+
navigator.serviceWorker.addEventListener("message", (event) => {
72+
const message = event.data as ServiceWorkerMessage
73+
74+
if (message.type === "NOTIFICATION_CLICK") {
75+
switch (message.action) {
76+
case "NAVIGATE_ENTRY": {
77+
router.navigate(message.data.url)
78+
break
79+
}
80+
}
81+
}
82+
})
83+
}

apps/renderer/src/sw.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,109 @@ import { CacheFirst, NetworkFirst } from "workbox-strategies"
66

77
declare let self: ServiceWorkerGlobalScope
88

9-
// 从 Service Worker 的 URL 参数中获取 PWA 状态
109
const isPWA = new URL(self.location.href).searchParams.get("pwa") === "true"
1110

1211
self.addEventListener("message", (event) => {
1312
if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting()
1413
})
1514

15+
interface NewEntryMessage {
16+
description: string
17+
entryId: string
18+
feedId: string
19+
title: string
20+
type: "new-entry"
21+
view: string
22+
}
23+
24+
type Message = NewEntryMessage
25+
26+
// Firebase Cloud Messaging handler
27+
self.addEventListener("push", (event) => {
28+
if (event.data) {
29+
const { data } = event.data.json()
30+
const payload = data as Message
31+
32+
switch (payload.type) {
33+
case "new-entry": {
34+
const notificationPromise = self.registration.showNotification(payload.title, {
35+
body: payload.description,
36+
icon: "https://app.follow.is/favicon.ico",
37+
data: {
38+
type: payload.type,
39+
feedId: payload.feedId,
40+
entryId: payload.entryId,
41+
view: Number.parseInt(payload.view),
42+
},
43+
})
44+
event.waitUntil(notificationPromise)
45+
break
46+
}
47+
}
48+
}
49+
})
50+
51+
self.addEventListener("notificationclick", (event) => {
52+
event.notification.close()
53+
54+
const notificationData = event.notification.data
55+
if (!notificationData) return
56+
57+
let urlToOpen: URL
58+
59+
switch (notificationData.type) {
60+
case "new-entry": {
61+
urlToOpen = new URL(
62+
`/feeds/${notificationData.feedId}/${notificationData.entryId}`,
63+
self.location.origin,
64+
)
65+
break
66+
}
67+
default: {
68+
urlToOpen = new URL("/", self.location.origin)
69+
break
70+
}
71+
}
72+
73+
const promiseChain = self.clients
74+
.matchAll({
75+
type: "window",
76+
includeUncontrolled: true,
77+
})
78+
.then((windowClients) => {
79+
if (windowClients.length > 0) {
80+
const client = windowClients[0]
81+
return client.focus()
82+
}
83+
return self.clients.openWindow(urlToOpen.href)
84+
})
85+
.then((client) => {
86+
if (client && "postMessage" in client) {
87+
switch (notificationData.type) {
88+
case "new-entry": {
89+
client.postMessage({
90+
type: "NOTIFICATION_CLICK",
91+
action: "NAVIGATE_ENTRY",
92+
data: {
93+
feedId: notificationData.feedId,
94+
entryId: notificationData.entryId,
95+
view: notificationData.view,
96+
url: urlToOpen.pathname,
97+
},
98+
})
99+
break
100+
}
101+
default: {
102+
console.warn("Unknown notification type:", notificationData.type)
103+
break
104+
}
105+
}
106+
}
107+
})
108+
109+
event.waitUntil(promiseChain)
110+
})
111+
16112
const preCacheExclude = new Set(["og-image.png", "opengraph-image.png"])
17113

18114
const precacheManifest = self.__WB_MANIFEST.filter((entry) => {
@@ -22,7 +118,6 @@ precacheAndRoute(precacheManifest)
22118

23119
cleanupOutdatedCaches()
24120

25-
// 根据 PWA 状态选择固定的策略
26121
const strategy = isPWA
27122
? new CacheFirst({ cacheName: "assets-cache-first" })
28123
: new NetworkFirst({

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"hotfix": "vv -c bump.hotfix.config.js",
3434
"lint": "eslint",
3535
"lint:fix": "eslint --fix",
36+
"mitproxy": "bash scripts/run-proxy.sh",
3637
"polyfill-optimize": "pnpx nolyfill install",
3738
"prepare": "pnpm exec simple-git-hooks && shx test -f .env || shx cp .env.example .env",
3839
"publish": "electron-vite build && electron-forge publish",

pnpm-lock.yaml

Lines changed: 51 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/mitproxy.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from mitmproxy import http
2+
3+
def request(flow: http.HTTPFlow) -> None:
4+
if flow.request.pretty_host == "app.follow.is":
5+
flow.request.host = "localhost"
6+
flow.request.port = 2233

0 commit comments

Comments
 (0)