Add push notifications

This commit is contained in:
Christoph Hagen 2025-02-18 22:05:19 +01:00
parent de6f474aea
commit 66a40e52a0
9 changed files with 285 additions and 4 deletions

View File

@ -176,6 +176,10 @@
E2B4820D2D5E811E005C309D /* TryFilesMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B4820C2D5E811E005C309D /* TryFilesMiddleware.swift */; };
E2B482102D5E9FF9005C309D /* RemotePush.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B4820F2D5E9FF5005C309D /* RemotePush.swift */; };
E2B482122D600AE0005C309D /* UploadSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482112D600AD1005C309D /* UploadSheet.swift */; };
E2B482182D63AF7A005C309D /* NotificationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482172D63AF74005C309D /* NotificationSheet.swift */; };
E2B4821A2D63AFF6005C309D /* NotificationSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B482192D63AFEE005C309D /* NotificationSender.swift */; };
E2B4821C2D63B062005C309D /* NotificationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B4821B2D63B05B005C309D /* NotificationRequest.swift */; };
E2B4821E2D63B096005C309D /* WebNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B4821D2D63B096005C309D /* WebNotification.swift */; };
E2B85F362C426BEE0047CD0C /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2B85F352C426BEE0047CD0C /* SFSafeSymbols */; };
E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F3A2C428F0D0047CD0C /* Post.swift */; };
E2B85F3D2C4293F80047CD0C /* FeedPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2B85F3C2C4293F80047CD0C /* FeedPageGenerator.swift */; };
@ -438,6 +442,11 @@
E2B4820C2D5E811E005C309D /* TryFilesMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TryFilesMiddleware.swift; sourceTree = "<group>"; };
E2B4820F2D5E9FF5005C309D /* RemotePush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePush.swift; sourceTree = "<group>"; };
E2B482112D600AD1005C309D /* UploadSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadSheet.swift; sourceTree = "<group>"; };
E2B482152D6365D5005C309D /* Readme.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Readme.md; sourceTree = "<group>"; };
E2B482172D63AF74005C309D /* NotificationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSheet.swift; sourceTree = "<group>"; };
E2B482192D63AFEE005C309D /* NotificationSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSender.swift; sourceTree = "<group>"; };
E2B4821B2D63B05B005C309D /* NotificationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRequest.swift; sourceTree = "<group>"; };
E2B4821D2D63B096005C309D /* WebNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebNotification.swift; sourceTree = "<group>"; };
E2B85F3A2C428F0D0047CD0C /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = "<group>"; };
E2B85F3C2C4293F80047CD0C /* FeedPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedPageGenerator.swift; sourceTree = "<group>"; };
E2B85F402C4294790047CD0C /* PageHead.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHead.swift; sourceTree = "<group>"; };
@ -871,6 +880,17 @@
path = Push;
sourceTree = "<group>";
};
E2B482162D63AF6F005C309D /* Notifications */ = {
isa = PBXGroup;
children = (
E2B4821D2D63B096005C309D /* WebNotification.swift */,
E2B4821B2D63B05B005C309D /* NotificationRequest.swift */,
E2B482192D63AFEE005C309D /* NotificationSender.swift */,
E2B482172D63AF74005C309D /* NotificationSheet.swift */,
);
path = Notifications;
sourceTree = "<group>";
};
E2B85F392C428F020047CD0C /* Model */ = {
isa = PBXGroup;
children = (
@ -991,6 +1011,7 @@
E2DD04722C276F31003BFF1F /* CHDataManagement */ = {
isa = PBXGroup;
children = (
E2B482162D63AF6F005C309D /* Notifications */,
E2B4820E2D5E9FF0005C309D /* Push */,
E2B482012D5D1325005C309D /* Server */,
E29D31372D043EB80051B7F4 /* Main */,
@ -1298,6 +1319,7 @@
E29D313D2D047C1B0051B7F4 /* LocalizedPageContentView.swift in Sources */,
E2FE0F242D2A8C21002963B7 /* TagDisplayView.swift in Sources */,
E2A37D2D2CED2EF10000979F /* OptionalTextField.swift in Sources */,
E2B4821C2D63B062005C309D /* NotificationRequest.swift in Sources */,
E2FE0F702D2D5235002963B7 /* TextIndicator.swift in Sources */,
E2A37D2B2CED2CC30000979F /* TagDetailView.swift in Sources */,
E2FD1D3D2D463CD800B48627 /* ContentLabel.swift in Sources */,
@ -1324,7 +1346,9 @@
E2581DED2C75202400F1F079 /* Tag.swift in Sources */,
E29D31302D03A2C50051B7F4 /* DescriptionField.swift in Sources */,
E29D31BE2D0DB85A0051B7F4 /* AudioPlayerCommand.swift in Sources */,
E2B482182D63AF7A005C309D /* NotificationSheet.swift in Sources */,
E29D31552D06D2CE0051B7F4 /* TagListView.swift in Sources */,
E2B4821A2D63AFF6005C309D /* NotificationSender.swift in Sources */,
E2FE0F3A2D2B3E4F002963B7 /* AudioPlayerSettings.swift in Sources */,
E2A21C032CB16C290060935B /* Environment+Language.swift in Sources */,
E2FE0F092D2689F0002963B7 /* TagPageGeneratorSource.swift in Sources */,
@ -1497,6 +1521,7 @@
E29D318B2D0B07EE0051B7F4 /* ContentBox.swift in Sources */,
E2FD1D2A2D35B74C00B48627 /* TextWithPopup.swift in Sources */,
E2FE0F4D2D2BCD30002963B7 /* PageLinkCommand.swift in Sources */,
E2B4821E2D63B096005C309D /* WebNotification.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -41,6 +41,9 @@ struct MainView: App {
@StateObject
private var upload: RemotePush = .init()
@StateObject
private var notifications: NotificationSender = .init()
@State
private var language: ContentLanguage = .english
@ -68,6 +71,9 @@ struct MainView: App {
@State
private var showUploadSheet = false
@State
private var showNotificationsSheet = false
@ViewBuilder
var sidebar: some View {
switch selection.tab {
@ -188,6 +194,11 @@ struct MainView: App {
Image(systemSymbol: .squareAndArrowUp)
}
}
ToolbarItem {
Button(action: { showNotificationsSheet = true }) {
Image(systemSymbol: .bell)
}
}
ToolbarItem {
Button(action: saveButtonPressed) {
Image(systemSymbol: content.saveState.symbol)
@ -235,6 +246,11 @@ struct MainView: App {
.environmentObject(content)
.environmentObject(upload)
}
.sheet(isPresented: $showNotificationsSheet) {
NotificationSheet()
.environmentObject(content)
.environmentObject(notifications)
}
}
}

View File

@ -1,7 +1,11 @@
import Foundation
import SwiftUI
final class GeneralSettings: ObservableObject {
@AppStorage("pushNotificationAccessToken")
var pushNotificationAccessToken: String?
@Published
var url: String
@ -20,13 +24,17 @@ final class GeneralSettings: ObservableObject {
@Published
var remotePathForUpload: String
init(url: String, linkPreviewImageWidth: Int, linkPreviewImageHeight: Int, remoteUserForUpload: String, remotePortForUpload: Int, remotePathForUpload: String) {
@Published
var urlForPushNotification: String?
init(url: String, linkPreviewImageWidth: Int, linkPreviewImageHeight: Int, remoteUserForUpload: String, remotePortForUpload: Int, remotePathForUpload: String, urlForPushNotification: String?) {
self.url = url
self.linkPreviewImageWidth = linkPreviewImageWidth
self.linkPreviewImageHeight = linkPreviewImageHeight
self.remoteUserForUpload = remoteUserForUpload
self.remotePortForUpload = remotePortForUpload
self.remotePathForUpload = remotePathForUpload
self.urlForPushNotification = urlForPushNotification
}
}
@ -39,7 +47,8 @@ extension GeneralSettings {
linkPreviewImageHeight: data.linkPreviewImageHeight,
remoteUserForUpload: data.remoteUserForUpload,
remotePortForUpload: data.remotePortForUpload,
remotePathForUpload: data.remotePathForUpload)
remotePathForUpload: data.remotePathForUpload,
urlForPushNotification: data.urlForPushNotification)
}
var data: Data {
@ -49,7 +58,8 @@ extension GeneralSettings {
linkPreviewImageHeight: linkPreviewImageHeight,
remoteUserForUpload: remoteUserForUpload,
remotePortForUpload: remotePortForUpload,
remotePathForUpload: remotePathForUpload)
remotePathForUpload: remotePathForUpload,
urlForPushNotification: urlForPushNotification)
}
struct Data: Codable, Equatable {
@ -59,5 +69,6 @@ extension GeneralSettings {
let remoteUserForUpload: String
let remotePortForUpload: Int
let remotePathForUpload: String
let urlForPushNotification: String?
}
}

View File

@ -111,7 +111,8 @@ extension GeneralSettings {
linkPreviewImageHeight: 630,
remoteUserForUpload: "user",
remotePortForUpload: 22,
remotePathForUpload: "/home/user/web")
remotePathForUpload: "/home/user/web",
urlForPushNotification: nil)
}
extension AudioPlayerSettings {

View File

@ -0,0 +1,21 @@
struct NotificationRequest {
let accessToken: String
/// The notificiations in the available languages
let notifications: [String : WebNotification]
init(accessToken: String, notifications: [String : WebNotification]) {
self.accessToken = accessToken
self.notifications = notifications
}
func notification(for language: String) -> WebNotification? {
notifications[language] ?? notifications["en"]
}
}
extension NotificationRequest: Codable {
}

View File

@ -0,0 +1,73 @@
import Foundation
final class NotificationSender: ObservableObject {
@Published
var isTransmittingToRemote = false
@Published
var lastPushWasSuccessful = true
init() {
}
func failedToCreateNotificationDueToMissingSettings() {
guard !isTransmittingToRemote else { return }
DispatchQueue.main.async {
self.lastPushWasSuccessful = false
}
}
func send(url: String, request: NotificationRequest) {
guard !isTransmittingToRemote else { return }
DispatchQueue.main.async {
self.isTransmittingToRemote = true
}
guard let url = URL(string: url) else {
print("Invalid notification url: \(url)")
didFinish(success: false)
return
}
let body: Data
do {
body = try JSONEncoder().encode(request)
} catch {
print("Failed to encode notification: \(error)")
didFinish(success: false)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = body
Task {
do {
let (_ , result) = try await URLSession.shared.data(for: request)
guard let response = result as? HTTPURLResponse else {
print("Invalid response \(result)")
didFinish(success: false)
return
}
guard response.statusCode == 200 else {
print("Failed to send notification: \(response.statusCode)")
didFinish(success: false)
return
}
didFinish(success: true)
} catch {
print("Failed to send notification request: \(error)")
didFinish(success: false)
return
}
}
}
private func didFinish(success: Bool) {
DispatchQueue.main.async {
self.isTransmittingToRemote = false
self.lastPushWasSuccessful = success
}
}
}

View File

@ -0,0 +1,105 @@
import SwiftUI
import SFSafeSymbols
struct NotificationSheet: View {
@EnvironmentObject
private var content: Content
@EnvironmentObject
private var notifications: NotificationSender
@Environment(\.dismiss)
private var dismiss
@State
private var englishTitle = ""
@State
private var englishMessage = ""
@State
private var germanTitle = ""
@State
private var germanMessage = ""
private var uploadSymbol: SFSymbol {
if notifications.isTransmittingToRemote {
return .squareAndArrowUpBadgeClock
}
if !notifications.lastPushWasSuccessful {
return .squareAndArrowUpTrianglebadgeExclamationmark
}
return .squareAndArrowUp
}
var header: String {
let user = content.settings.general.remoteUserForUpload
let port = content.settings.general.remotePortForUpload
let url = content.settings.general.remotePathForUpload.withHttpPrefixRemoved
let path = content.settings.general.remotePathForUpload
return "\(user)@\(url):\(port)/\(path)"
}
var isIncomplete: Bool {
englishTitle.isEmpty || germanTitle.isEmpty || englishMessage.isEmpty || germanMessage.isEmpty
}
var body: some View {
VStack {
HStack {
Text("Send a notification to subscribers")
.font(.headline)
Spacer()
Button("Close", action: { dismiss() })
}
StringPropertyView(
title: "English title",
text: $englishTitle,
footer: "The title for devices using the english language")
StringPropertyView(
title: "English message",
text: $englishMessage,
footer: "The message for devices using the english language")
StringPropertyView(
title: "German title",
text: $germanTitle,
footer: "The title for devices using the german language")
StringPropertyView(
title: "German message",
text: $germanMessage,
footer: "The message for devices using the german language")
Button("Send", action: send)
.disabled(notifications.isTransmittingToRemote || isIncomplete)
}
.padding()
.frame(minWidth: 300, idealWidth: 400, idealHeight: 500)
}
private func send() {
guard let accessToken = content.settings.general.pushNotificationAccessToken,
let url = content.settings.general.urlForPushNotification else {
return
}
let english = WebNotification(title: englishTitle, body: englishMessage)
let german = WebNotification(title: germanTitle, body: germanMessage)
let notifications: [String : WebNotification] = [
"en": english,
"de": german
]
let request = NotificationRequest.init(
accessToken: accessToken,
notifications: notifications)
self.notifications.send(url: url, request: request)
}
}

View File

@ -0,0 +1,19 @@
struct WebNotification {
/// The title of the notification
/// - Note: Not supported on Safari for iOS
let title: String
/// The main content/message
let body: String
init(title: String, body: String) {
self.title = title
self.body = body
}
}
extension WebNotification: Codable {
}

View File

@ -37,6 +37,16 @@ struct GeneralSettingsDetailView: View {
title: "Upload Folder",
text: $generalSettings.remotePathForUpload,
footer: "The path to the folder on the server where the files should be uploaded to")
OptionalStringPropertyView(
title: "Push Notification URL",
text: $generalSettings.urlForPushNotification,
footer: "The url to send push notifications to")
OptionalStringPropertyView(
title: "Push Notification Access Token",
text: $generalSettings.pushNotificationAccessToken,
footer: "The access token to use for sending push notifications")
}
.padding()
}