diff options
| -rw-r--r-- | Hotline.xcodeproj/project.pbxproj | 14 | ||||
| -rw-r--r-- | Hotline/Application-macOS.swift | 9 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/User.imageset/User.png | bin | 475 -> 459 bytes | |||
| -rw-r--r-- | Hotline/Assets.xcassets/User.imageset/User@2x.png | bin | 800 -> 838 bytes | |||
| -rw-r--r-- | Hotline/Assets.xcassets/User.imageset/User@3x.png | bin | 1160 -> 1329 bytes | |||
| -rw-r--r-- | Hotline/Models/Bookmark.swift | 269 | ||||
| -rw-r--r-- | Hotline/Models/BookmarksOld.swift (renamed from Hotline/Models/Bookmarks.swift) | 36 | ||||
| -rw-r--r-- | Hotline/Models/Server.swift | 6 | ||||
| -rw-r--r-- | Hotline/Sounds/Application-iOS.swift | 5 | ||||
| -rw-r--r-- | Hotline/macOS/NewsView.swift | 3 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 5 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 607 |
12 files changed, 550 insertions, 404 deletions
diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index e6d50a4..9237db3 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ DA0D698D2B1E7CF700C71DF5 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */; platformFilter = ios; }; DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */; platformFilter = ios; }; DA0D69912B1E894800C71DF5 /* FilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0D69902B1E894800C71DF5 /* FilesView.swift */; platformFilter = ios; }; + DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA20BBE02BF5237600B94E7C /* Bookmark.swift */; }; DA2863D82B37AD1C00A7D050 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863D72B37AD1C00A7D050 /* SettingsView.swift */; platformFilters = (macos, ); }; DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863D92B37BF6E00A7D050 /* Preferences.swift */; }; DA2863DD2B3E8B7000A7D050 /* FilePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2863DC2B3E8B7000A7D050 /* FilePreview.swift */; }; @@ -64,7 +65,7 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; - DABE8C062B57A06100884D28 /* Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C052B57A06100884D28 /* Bookmarks.swift */; }; + DABE8C062B57A06100884D28 /* BookmarksOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C052B57A06100884D28 /* BookmarksOld.swift */; }; DABFCC292B1530DC009F40D2 /* FoundationExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */; }; DAC002192B21630900A6C290 /* SwiftUIExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */; }; DAC3D97E2BC0F1ED00A727C9 /* Application-iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D97D2BC0F1ED00A727C9 /* Application-iOS.swift */; platformFilter = ios; }; @@ -89,6 +90,7 @@ DA0D698C2B1E7CF700C71DF5 /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = "<group>"; }; DA0D698E2B1E841600C71DF5 /* MessageBoardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBoardView.swift; sourceTree = "<group>"; }; DA0D69902B1E894800C71DF5 /* FilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesView.swift; sourceTree = "<group>"; }; + DA20BBE02BF5237600B94E7C /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; }; DA2863D72B37AD1C00A7D050 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; DA2863D92B37BF6E00A7D050 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; }; DA2863DB2B37DBE000A7D050 /* Hotline.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Hotline.entitlements; sourceTree = "<group>"; }; @@ -142,7 +144,7 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = "<group>"; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = "<group>"; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = "<group>"; }; - DABE8C052B57A06100884D28 /* Bookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmarks.swift; sourceTree = "<group>"; }; + DABE8C052B57A06100884D28 /* BookmarksOld.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksOld.swift; sourceTree = "<group>"; }; DABFCC282B1530DC009F40D2 /* FoundationExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationExtensions.swift; sourceTree = "<group>"; }; DAC002182B21630900A6C290 /* SwiftUIExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIExtensions.swift; sourceTree = "<group>"; }; DAC3D97D2BC0F1ED00A727C9 /* Application-iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Application-iOS.swift"; path = "Sounds/Application-iOS.swift"; sourceTree = "<group>"; }; @@ -294,7 +296,7 @@ DADDB28E2B238D850024040D /* Hotline.swift */, DADDB28A2B22B31F0024040D /* Tracker.swift */, DAC3D9822BC33FD000A727C9 /* ApplicationState.swift */, - DABE8C052B57A06100884D28 /* Bookmarks.swift */, + DABE8C052B57A06100884D28 /* BookmarksOld.swift */, DA2863D92B37BF6E00A7D050 /* Preferences.swift */, DADDB28C2B22B5920024040D /* Server.swift */, DA32CD482B2931640053B98B /* User.swift */, @@ -307,6 +309,7 @@ DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */, DA72A0E12B4DAA4000A0F48A /* NewsArticle.swift */, DAAEE66C2B475F1400A5BA07 /* PreviewFileInfo.swift */, + DA20BBE02BF5237600B94E7C /* Bookmark.swift */, ); path = Models; sourceTree = "<group>"; @@ -318,6 +321,7 @@ DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAE734FE2B2E6750000C56F6 /* ChatView.swift */, DAE735042B3218D8000C56F6 /* NewsView.swift */, + DA2D98112BF29D5F0027E4BD /* NewsItemView.swift */, DA72A0DC2B4CD0BF00A0F48A /* NewsEditorView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, DAE734FC2B2E65E9000C56F6 /* MessageBoardView.swift */, @@ -330,7 +334,6 @@ DA55AC762BE589F700034857 /* AboutView.swift */, DA65499B2BEC3FBD00EDB697 /* ServerAgreementView.swift */, DA6549992BEC280E00EDB697 /* ServerMessageView.swift */, - DA2D98112BF29D5F0027E4BD /* NewsItemView.swift */, ); path = macOS; sourceTree = "<group>"; @@ -448,8 +451,9 @@ DA55AC732BE42AF000034857 /* AsyncLinkPreview.swift in Sources */, DA65499E2BEC438A00EDB697 /* NSWindowBridge.swift in Sources */, DAE735032B30C0BB000C56F6 /* MessageView.swift in Sources */, - DABE8C062B57A06100884D28 /* Bookmarks.swift in Sources */, + DABE8C062B57A06100884D28 /* BookmarksOld.swift in Sources */, DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */, + DA20BBE12BF5237600B94E7C /* Bookmark.swift in Sources */, DA9CAFCB2B126E3300CDA197 /* HotlineTrackerClient.swift in Sources */, DA2863DA2B37BF6E00A7D050 /* Preferences.swift in Sources */, DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, diff --git a/Hotline/Application-macOS.swift b/Hotline/Application-macOS.swift index 3ddf903..f6b5bc5 100644 --- a/Hotline/Application-macOS.swift +++ b/Hotline/Application-macOS.swift @@ -2,8 +2,6 @@ import SwiftUI import SwiftData import UniformTypeIdentifiers -#if os(macOS) - @Observable final class AppLaunchState { static let shared = AppLaunchState() @@ -35,8 +33,7 @@ struct Application: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @State private var soundEffects = SoundEffectPlayer() - @State private var bookmarks = Bookmarks() + @State private var bookmarks = BookmarksOld() @State private var hotlinePanel: HotlinePanel? = nil @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? @@ -49,6 +46,7 @@ struct Application: App { .frame(minWidth: 250, minHeight: 250) .environment(bookmarks) } + .modelContainer(for: Bookmark.self, isAutosaveEnabled: true, isUndoEnabled: true) .defaultSize(width: 700, height: 550) .defaultPosition(.center) .keyboardShortcut(.init("R"), modifiers: .command) @@ -82,7 +80,6 @@ struct Application: App { WindowGroup(id: "server", for: Server.self) { server in ServerView(server: server) .frame(minWidth: 430, minHeight: 300) - .environment(soundEffects) .environment(bookmarks) } defaultValue: { Server(name: nil, description: nil, address: "") @@ -241,5 +238,3 @@ struct Application: App { } } } - -#endif diff --git a/Hotline/Assets.xcassets/User.imageset/User.png b/Hotline/Assets.xcassets/User.imageset/User.png Binary files differindex 0bf463b..0a5c57e 100644 --- a/Hotline/Assets.xcassets/User.imageset/User.png +++ b/Hotline/Assets.xcassets/User.imageset/User.png diff --git a/Hotline/Assets.xcassets/User.imageset/User@2x.png b/Hotline/Assets.xcassets/User.imageset/User@2x.png Binary files differindex 27712aa..b64c6a5 100644 --- a/Hotline/Assets.xcassets/User.imageset/User@2x.png +++ b/Hotline/Assets.xcassets/User.imageset/User@2x.png diff --git a/Hotline/Assets.xcassets/User.imageset/User@3x.png b/Hotline/Assets.xcassets/User.imageset/User@3x.png Binary files differindex aeda6cf..cb593c9 100644 --- a/Hotline/Assets.xcassets/User.imageset/User@3x.png +++ b/Hotline/Assets.xcassets/User.imageset/User@3x.png diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift new file mode 100644 index 0000000..160de30 --- /dev/null +++ b/Hotline/Models/Bookmark.swift @@ -0,0 +1,269 @@ +import Foundation +import SwiftData + +enum BookmarkType: Codable { + case tracker + case server + case temporary +} + +@Model +final class Bookmark { + var type: BookmarkType + var order: Int = 0 + + var name: String + var address: String + var port: Int + + @Attribute(.allowsCloudEncryption) + var login: String? + + @Attribute(.allowsCloudEncryption) + var password: String? + + @Attribute(.ephemeral) + var expanded: Bool = false + + @Attribute(.ephemeral) + var loading: Bool = false + + @Attribute(.ephemeral) + var serverDescription: String? = nil + + @Transient + var servers: [Bookmark] = [] + + @Transient + var displayAddress: String { + switch self.type { + case .tracker: + if self.port == HotlinePorts.DefaultTrackerPort { + return self.address + } + else { + return "\(self.address):\(String(self.port))" + } + + case .server, .temporary: + if self.port == HotlinePorts.DefaultServerPort { + return self.address + } + else { + return "\(self.address):\(String(self.port))" + } + } + +// if let s = server { +// if s.port == HotlinePorts.DefaultServerPort { +// return s.address +// } +// else { +// return "\(s.address):\(s.port)" +// } +// } +// else if let b = bookmark { +// if b.port == HotlinePorts.DefaultServerPort { +// return b.address +// } +// else { +// return "\(b.address):\(b.port)" +// } +// } + +// return nil + } + + @Transient + var server: Server? { + switch self.type { + case .tracker: + return nil + + case .server, .temporary: + return Server(name: self.name, description: self.serverDescription, address: self.address, port: self.port, login: self.login, password: self.password) + } + } + + static let DefaultBookmarks: [Bookmark] = [ + Bookmark(type: .server, name: "The Mobius Strip", address: "67.174.208.111", port: HotlinePorts.DefaultServerPort), + Bookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.com", port: HotlinePorts.DefaultServerPort), + Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com", port: HotlinePorts.DefaultTrackerPort) + ] + + init(type: BookmarkType, name: String, address: String, port: Int, login: String? = nil, password: String? = nil) { + self.type = type + self.name = name + self.address = address + self.port = port + + self.login = login + self.password = password + } + + init(temporaryServer server: Server) { + self.type = .temporary + + self.name = server.name ?? server.address + self.address = server.address + self.port = server.port + + self.serverDescription = server.description + } + + static func populateDefaults(force: Bool = false, context: ModelContext) { + if force || Bookmark.fetchCount(context: context) == 0 { + Bookmark.add(Bookmark.DefaultBookmarks, context: context) + } + } + + static func fetchAll(context: ModelContext) -> [Bookmark] { + let fetchDescriptor = FetchDescriptor<Bookmark>(sortBy: [.init(\.order)]) + do { + let bookmarks: [Bookmark] = try context.fetch(fetchDescriptor) + return bookmarks + } + catch { + return [] + } + } + + static func fetchCount(context: ModelContext) -> Int { + let descriptor = FetchDescriptor<Bookmark>() + return (try? context.fetchCount(descriptor)) ?? 0 + } + + static func deleteAll(context: ModelContext) { + try? context.delete(model: Bookmark.self) + } + + static func add(_ bookmark: Bookmark, context: ModelContext) { + guard bookmark.type != .temporary else { + print("Bookmark: Attempting to add temporary bookmark to store. Aborting.") + return + } + + let existingBookmarks = Bookmark.fetchAll(context: context) + + // Reindex bookmarks before insert. + for existingBookmark in existingBookmarks { + existingBookmark.order += 1 + } + + // Insert new bookmark at start. + bookmark.order = 0 + context.insert(bookmark) + } + + static func add(_ bookmarks: [Bookmark], context: ModelContext) { + let existingBookmarks = Bookmark.fetchAll(context: context) + + // Reindex bookmarks before insert. + for existingBookmark in existingBookmarks { + existingBookmark.order += bookmarks.count + } + + // Insert new bookmarks at start. + var bookmarkIndex = 0 + for newBookmark in bookmarks { + newBookmark.order = bookmarkIndex + context.insert(newBookmark) + bookmarkIndex += 1 + + print("Bookmark: added \(newBookmark.name)") + } + } + + static func delete(_ bookmark: Bookmark, context: ModelContext) { + // Delete bookmark + context.delete(bookmark) + + // Reindex bookmarks + let existingBookmarks = Bookmark.fetchAll(context: context) + var index = 0 + for existingBookmark in existingBookmarks { + existingBookmark.order = index + index += 1 + } + } + + static func delete(at indexes: IndexSet, context: ModelContext) { + var existingBookmarks = Bookmark.fetchAll(context: context) +// existingBookmarks.remove(atOffsets: indexes) + let bookmarksToDelete = indexes.map { existingBookmarks[$0] } + + // Delete bookmark + for bookmark in bookmarksToDelete { + context.delete(bookmark) + } + + // Reindex bookmarks + var index = 0 + existingBookmarks.remove(atOffsets: indexes) + for existingBookmark in existingBookmarks { + existingBookmark.order = index + index += 1 + } + + do { + try context.save() + } + catch { + print("Bookmark: Failed to save bookmark deletions") + } + } + + static func move(_ indexes: IndexSet, to newIndex: Int, context: ModelContext) { + guard Bookmark.fetchCount(context: context) >= indexes.count else { + print("Bookmark: Not enough bookmarks to move requested set") + return + } + + // Perform move + var existingBookmarks = Bookmark.fetchAll(context: context) + existingBookmarks.move(fromOffsets: indexes, toOffset: newIndex) + + // Reindex bookmarks + var index = 0 + for existingBookmark in existingBookmarks { + existingBookmark.order = index + index += 1 + } + + do { + try context.save() + } + catch { + print("Bookmark: Failed to save bookmark reordering") + } + } + + func fetchServers() async { + guard self.type == .tracker else { + // self.loading = false + return + } + + DispatchQueue.main.sync { + self.loading = true + } + + var fetchedBookmarks: [Bookmark] = [] + + let client = HotlineTrackerClient() + if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: self.address, port: self.port) { + for fetchedServer in fetchedServers { + if let serverName = fetchedServer.name { + let server = Server(name: serverName, description: fetchedServer.description, address: fetchedServer.address, port: Int(fetchedServer.port), users: Int(fetchedServer.users)) + fetchedBookmarks.append(Bookmark(temporaryServer: server)) + } + } + } + + let newServers = fetchedBookmarks + DispatchQueue.main.sync { + self.servers = newServers + self.loading = false + } + } +} diff --git a/Hotline/Models/Bookmarks.swift b/Hotline/Models/BookmarksOld.swift index 3f4971c..0a7bb16 100644 --- a/Hotline/Models/Bookmarks.swift +++ b/Hotline/Models/BookmarksOld.swift @@ -5,20 +5,20 @@ extension NSNotification { static let BookmarkRemoved = Notification.Name("BookmarkRemoved") } -enum BookmarkType: String, Codable { +enum BookmarkOldType: String, Codable { case tracker = "tracker" case server = "server" } -struct Bookmark: Codable, Equatable { - let type: BookmarkType +struct BookmarkOld: Codable, Equatable { + let type: BookmarkOldType let name: String let address: String let port: Int let login: String? let password: String? - init(type: BookmarkType, name: String, address: String, port: Int = HotlinePorts.DefaultServerPort, login: String? = nil, password: String? = nil) { + init(type: BookmarkOldType, name: String, address: String, port: Int = HotlinePorts.DefaultServerPort, login: String? = nil, password: String? = nil) { self.type = type self.name = name self.address = address @@ -28,20 +28,20 @@ struct Bookmark: Codable, Equatable { } } -@Observable final class Bookmarks { - var bookmarks: [Bookmark]? = nil +@Observable final class BookmarksOld { + var bookmarks: [BookmarkOld]? = nil - static let DefaultBookmarks: [Bookmark] = [ - Bookmark(type: .server, name: "System 7 Today", address: "hotline.system7today.com"), - Bookmark(type: .server, name: "The Mobius Strip", address: "67.174.208.111"), - Bookmark(type: .tracker, name: "Featured Servers", address: "hltracker.com"), + static let DefaultBookmarks: [BookmarkOld] = [ + BookmarkOld(type: .server, name: "System 7 Today", address: "hotline.system7today.com"), + BookmarkOld(type: .server, name: "The Mobius Strip", address: "67.174.208.111"), + BookmarkOld(type: .tracker, name: "Featured Servers", address: "hltracker.com"), ] init() { self.load() } - func apply(_ newBookmarks: [Bookmark], save shouldSave: Bool = true) { + func apply(_ newBookmarks: [BookmarkOld], save shouldSave: Bool = true) { self.bookmarks = newBookmarks if shouldSave { self.save() @@ -53,21 +53,21 @@ struct Bookmark: Codable, Equatable { let jsonData: Data? = jsonString?.data(using: .utf8, allowLossyConversion: false) let decoder = JSONDecoder() - var decodedBookmarks = try? decoder.decode([Bookmark].self, from: jsonData ?? Data()) + var decodedBookmarks = try? decoder.decode([BookmarkOld].self, from: jsonData ?? Data()) if decodedBookmarks == nil || decodedBookmarks?.isEmpty == true { print("Bookmarks: using default bookmarks") - decodedBookmarks = Bookmarks.DefaultBookmarks + decodedBookmarks = BookmarksOld.DefaultBookmarks } else { print("Bookmarks: using saved bookmarks") } - self.bookmarks = [Bookmark](decodedBookmarks!) + self.bookmarks = [BookmarkOld](decodedBookmarks!) } func save() { var bookmarksToSave = self.bookmarks - if bookmarksToSave == Bookmarks.DefaultBookmarks { + if bookmarksToSave == BookmarksOld.DefaultBookmarks { print("Bookmarks: skipping saving default bookmarks") bookmarksToSave = [] } @@ -83,7 +83,7 @@ struct Bookmark: Codable, Equatable { // MARK: - - func add(_ bookmark: Bookmark, save shouldSave: Bool = true) { + func add(_ bookmark: BookmarkOld, save shouldSave: Bool = true) { self.bookmarks?.insert(bookmark, at: 0) if shouldSave { @@ -95,7 +95,7 @@ struct Bookmark: Codable, Equatable { } } - func delete(_ bookmark: Bookmark, save shouldSave: Bool = true) -> Bool { + func delete(_ bookmark: BookmarkOld, save shouldSave: Bool = true) -> Bool { if let i = self.bookmarks?.firstIndex(where: { b in b.address.lowercased() == bookmark.address.lowercased() && b.port == bookmark.port }) { self.bookmarks?.remove(at: i) @@ -112,7 +112,7 @@ struct Bookmark: Codable, Equatable { return false } - func update(_ bookmark: Bookmark, save shouldSave: Bool = true) -> Bool { + func update(_ bookmark: BookmarkOld, save shouldSave: Bool = true) -> Bool { if let i = self.bookmarks?.firstIndex(where: { b in b.address.lowercased() == bookmark.address.lowercased() && b.port == bookmark.port }) { self.bookmarks?[i] = bookmark if shouldSave { diff --git a/Hotline/Models/Server.swift b/Hotline/Models/Server.swift index 27d45c6..e0fe38b 100644 --- a/Hotline/Models/Server.swift +++ b/Hotline/Models/Server.swift @@ -10,14 +10,14 @@ struct Server: Codable { var login: String var password: String - init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String = "", password: String = "") { + init(name: String?, description: String?, address: String, port: Int = HotlinePorts.DefaultServerPort, users: Int = 0, login: String? = nil, password: String? = nil) { self.name = name self.description = description self.address = address.lowercased() self.port = port self.users = users - self.login = login - self.password = password + self.login = login ?? "" + self.password = password ?? "" } init?(url: URL) { diff --git a/Hotline/Sounds/Application-iOS.swift b/Hotline/Sounds/Application-iOS.swift index 055c8f1..dc3a9d0 100644 --- a/Hotline/Sounds/Application-iOS.swift +++ b/Hotline/Sounds/Application-iOS.swift @@ -2,13 +2,10 @@ import SwiftUI import SwiftData import UniformTypeIdentifiers -#if os(iOS) - @main struct Application: App { private var model = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) - @State private var soundEffects = SoundEffectPlayer() @State private var bookmarks = Bookmarks() @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? @@ -21,5 +18,3 @@ struct Application: App { } } } - -#endif diff --git a/Hotline/macOS/NewsView.swift b/Hotline/macOS/NewsView.swift index 6f24193..8e10b19 100644 --- a/Hotline/macOS/NewsView.swift +++ b/Hotline/macOS/NewsView.swift @@ -13,7 +13,7 @@ struct NewsView: View { @State private var splitFraction = FractionHolder.usingUserDefaults(0.25, key: "News Split Fraction") @State private var editorOpen: Bool = false @State private var replyOpen: Bool = false - @State private var loading: Bool = true + @State private var loading: Bool = false var body: some View { Group { @@ -65,6 +65,7 @@ struct NewsView: View { } .task { if !model.newsLoaded { + loading = true await model.getNewsList() loading = false } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index acd3ac2..64c3fbd 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -127,8 +127,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { } struct ServerView: View { - @Environment(SoundEffectPlayer.self) private var soundEffects: SoundEffectPlayer - @Environment(Bookmarks.self) private var bookmarks: Bookmarks + @Environment(BookmarksOld.self) private var bookmarks: BookmarksOld @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.controlActiveState) private var controlActiveState @@ -335,7 +334,7 @@ struct ServerView: View { let password: String? = connectPassword.isEmpty ? nil : connectPassword if !host.isEmpty { - let _ = bookmarks.add(Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password)) + let _ = bookmarks.add(BookmarkOld(type: .server, name: name, address: host, port: port, login: login, password: password)) } } } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index f9755f8..ab70389 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -1,146 +1,92 @@ import SwiftUI +import SwiftData struct TrackerView: View { - @Environment(Bookmarks.self) private var bookmarks: Bookmarks +// @Environment(BookmarksOld.self) private var bookmarks: BookmarksOld @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.modelContext) private var modelContext - @MainActor - func refresh() async { - - // When a tracker is selected, refresh only that tracker. - if - let selectedItem = selection, - let bookmark = selectedItem.bookmark, - bookmark.type == .tracker { - if !selectedItem.expanded { - selectedItem.expanded = true - } - else { - await selectedItem.loadServers() - } - return - } - - // Otherwise refresh/expand all trackers. - for server in self.servers { - if - let bookmark = server.bookmark, - bookmark.type == .tracker { - if !server.expanded { - server.expanded = true - } - else { - Task { - await server.loadServers() - } - } - } - } - } - - @State private var servers: [TrackerItem] = [] - @State private var selection: TrackerItem? = nil - - @State private var scrollOffset: CGFloat = CGFloat.zero - @State private var initialLoadComplete = false @State private var refreshing = false - @State private var topBarOpacity: Double = 1.0 - @State private var connectVisible = false - @State private var connectDismissed = true - @State private var serverVisible = false - @State private var trackerSheetPresented: Bool = false - @State private var trackerName: String = "" - @State private var trackerAddress: String = "" + @State private var trackerSheetBookmark: Bookmark? = nil - var trackerBookmarkSheet: some View { - VStack(alignment: .leading) { - Text("Type the address and name of a Hotline Tracker:") - .foregroundStyle(.secondary) - .padding(.bottom, 8) - Form { - Group { - TextField(text: $trackerAddress) { - Text("Address:") - } - TextField(text: $trackerName, prompt: Text("Optional")) { - Text("Name:") + @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] + @State private var selection: Bookmark? = nil + + var body: some View { + List(selection: $selection) { + ForEach(bookmarks, id: \.self) { bookmark in + TrackerItemView(bookmark: bookmark) + .tag(bookmark) + + if bookmark.type == .tracker && bookmark.expanded { + ForEach(bookmark.servers, id: \.self) { trackedServer in + TrackerItemView(bookmark: trackedServer) + .moveDisabled(true) + .deleteDisabled(true) + .tag(trackedServer) } } - .textFieldStyle(.roundedBorder) - .controlSize(.large) } - } - .frame(width: 300) - .fixedSize(horizontal: true, vertical: true) - .padding() - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Add Tracker") { - var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) - let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) - - if displayName.isEmpty { - displayName = host - } - - if !displayName.isEmpty && !host.isEmpty { - if !host.isEmpty { - let _ = bookmarks.add(Bookmark(type: .tracker, name: displayName, address: host, port: port)) - trackerSheetPresented = false - trackerName = "" - trackerAddress = "" - } - } - } + .onMove { movedIndexes, destinationIndex in + Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext) } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - trackerSheetPresented = false - trackerName = "" - trackerAddress = "" - } + .onDelete { deletedIndexes in + Bookmark.delete(at: deletedIndexes, context: modelContext) } } - } - - var body: some View { - List($servers, id: \.self, editActions: [.move], selection: $selection) { $item in - TrackerItemView(item: item, depth: 0) - .deleteDisabled(!item.editable) - .moveDisabled(!item.editable) - .tag(item) + .onDeleteCommand { + if let bookmark = selection, + bookmark.type != .temporary { + Bookmark.delete(bookmark, context: modelContext) + } } .environment(\.defaultMinListRowHeight, 34) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) - .onDeleteCommand { - if let sel = selection, let bookmark = sel.bookmark { - let _ = bookmarks.delete(bookmark) - if let i = self.servers.firstIndex(where: { $0.id == sel.id }) { - self.servers.remove(at: i) - } - selection = nil - } + .onAppear { +// Bookmark.deleteAll(context: modelContext) + + // Make sure default bookmarks are there when empty. + Bookmark.populateDefaults(context: modelContext) } - .contextMenu(forSelectionType: TrackerItem.self) { items in + .contextMenu(forSelectionType: Bookmark.self) { items in if let item = items.first { - if let server = item.server { + if item.type == .temporary { Button { - let _ = bookmarks.add(Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port)) + let newBookmark = Bookmark(type: .server, name: item.name, address: item.address, port: item.port, login: item.login, password: item.password) + Bookmark.add(newBookmark, context: modelContext) } label: { Label("Bookmark", systemImage: "bookmark") } + + Divider() + } + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(item.displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") } - if let address = item.displayAddress { + if item.type == .tracker || item.type == .server { + Divider() + + if item.type == .tracker { + Button { + trackerSheetBookmark = item + } label: { + Label("Edit Tracker...", systemImage: "pencil") + } + } + Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(address, forType: .string) + Bookmark.delete(item, context: modelContext) } label: { - Label("Copy Address", systemImage: "doc.on.doc") + Label(item.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") } } } @@ -149,41 +95,42 @@ struct TrackerView: View { return } - if - let bookmark = clickedItem.bookmark, - bookmark.type == .tracker { - clickedItem.expanded.toggle() + if clickedItem.type == .tracker { + if let event = NSApp.currentEvent, + event.modifierFlags.contains(.option) { + trackerSheetBookmark = clickedItem + } + else { + clickedItem.expanded.toggle() + } } else if let server = clickedItem.server { openWindow(id: "server", value: server) } - else if let bookmark = clickedItem.bookmark, bookmark.type == .server { - let server = Server(name: bookmark.name, description: nil, address: bookmark.address, port: bookmark.port, login: bookmark.login ?? "", password: bookmark.password ?? "") - openWindow(id: "server", value: server) - } } .onKeyPress(.rightArrow) { if - let selectedItem = selection, - let bookmark = selectedItem.bookmark, + let bookmark = selection, bookmark.type == .tracker { - selectedItem.expanded = true + bookmark.expanded = true return .handled } return .ignored } .onKeyPress(.leftArrow) { if - let selectedItem = selection, - let bookmark = selectedItem.bookmark, + let bookmark = selection, bookmark.type == .tracker { - selectedItem.expanded = false + bookmark.expanded = false return .handled } return .ignored } + .sheet(item: $trackerSheetBookmark) { item in + TrackerBookmarkSheet(item) + } .sheet(isPresented: $trackerSheetPresented) { - trackerBookmarkSheet + TrackerBookmarkSheet() } .navigationTitle("Servers") .toolbar { @@ -196,16 +143,16 @@ struct TrackerView: View { .frame(width: 9) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) } + ToolbarItem(placement: .primaryAction) { Button { - Task { - initialLoadComplete = false - await refresh() - initialLoadComplete = true - } + refreshing = true + refresh() + refreshing = false } label: { Label("Refresh", systemImage: "arrow.clockwise") } + .disabled(refreshing) .help("Refresh Trackers") } @@ -227,31 +174,15 @@ struct TrackerView: View { .help("Connect to Server") } } - .task { - guard let bookmarks = bookmarks.bookmarks else { - return - } - // Add initial items to tracker list. - var items: [TrackerItem] = [] - for bookmark in bookmarks { - items.append(TrackerItem(bookmark: bookmark)) - } - self.servers = items - } - .onChange(of: servers) { - Task { - saveBookmarks() - } - } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.BookmarkAdded)) { notification in - guard let bookmarks = bookmarks.bookmarks, let userInfo = notification.userInfo else { - return - } - - if let i = userInfo["index"] as? Int, bookmarks.count > i { - self.servers.insert(TrackerItem(bookmark: bookmarks[i]), at: i) - } - } +// .onReceive(NotificationCenter.default.publisher(for: NSNotification.BookmarkAdded)) { notification in +// guard let bookmarks = bookmarks.bookmarks, let userInfo = notification.userInfo else { +// return +// } +// +// if let i = userInfo["index"] as? Int, bookmarks.count > i { +// self.servers.insert(TrackerItem(bookmark: bookmarks[i]), at: i) +// } +// } .onOpenURL(perform: { url in if let s = Server(url: url) { openWindow(id: "server", value: s) @@ -259,34 +190,131 @@ struct TrackerView: View { }) } - private func saveBookmarks() { - var newBookmarks: [Bookmark] = [] - for server in self.servers { - if let b = server.bookmark { - newBookmarks.append(b) + func refresh() { + // When a tracker is selected, refresh only that tracker. + if + let selectedBookmark = selection, + selectedBookmark.type == .tracker { + if !selectedBookmark.expanded { + selectedBookmark.expanded = true + } + else { + Task { + await selectedBookmark.fetchServers() + } } + return } - bookmarks.apply(newBookmarks) + // Otherwise refresh/expand all trackers. + for bookmark in self.bookmarks { + if bookmark.type == .tracker { + if !bookmark.expanded { + bookmark.expanded = true + } + else { + Task { + await bookmark.fetchServers() + } + } + } + } } } -struct TrackerItemView: View { - @State var expanded = false - @State var loading = false +struct TrackerBookmarkSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var bookmark: Bookmark? = nil + @State private var trackerAddress: String = "" + @State private var trackerName: String = "" - var item: TrackerItem - let depth: Int + init() { + + } + + init(_ editingBookmark: Bookmark) { + _bookmark = .init(initialValue: editingBookmark) + _trackerAddress = .init(initialValue: editingBookmark.displayAddress) + _trackerName = .init(initialValue: editingBookmark.name) + } var body: some View { - HStack { - if - let bookmark = item.bookmark, - bookmark.type == .tracker { + VStack(alignment: .leading) { + Text("Type the address and name of a Hotline Tracker:") + .foregroundStyle(.secondary) + .padding(.bottom, 8) + Form { + Group { + TextField(text: $trackerAddress) { + Text("Address:") + } + TextField(text: $trackerName, prompt: Text("Optional")) { + Text("Name:") + } + } + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + } + .frame(width: 300) + .fixedSize(horizontal: true, vertical: true) + .padding() + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(self.bookmark != nil ? "Save Tracker" : "Add Tracker") { + var displayName = trackerName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Tracker.parseTrackerAddressAndPort(trackerAddress) + + if displayName.isEmpty { + displayName = host + } + + if !displayName.isEmpty && !host.isEmpty { + if !host.isEmpty { + if self.bookmark != nil { + // We're editing an existing bookmark. + self.bookmark?.name = displayName + self.bookmark?.address = host + self.bookmark?.port = port + } + else { + // We're creating a new bookmark. + let newBookmark = Bookmark(type: .tracker, name: displayName, address: host, port: port) + Bookmark.add(newBookmark, context: modelContext) + } + + self.trackerName = "" + self.trackerAddress = "" + + dismiss() + } + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.trackerName = "" + self.trackerAddress = "" + + dismiss() + } + } + } + } +} + +struct TrackerItemView: View { + let bookmark: Bookmark + + var body: some View { + HStack(alignment: .center, spacing: 8) { + if bookmark.type == .tracker { Button { - item.expanded.toggle() + bookmark.expanded.toggle() } label: { - Text(Image(systemName: item.expanded ? "chevron.down" : "chevron.right")) + Text(Image(systemName: bookmark.expanded ? "chevron.down" : "chevron.right")) .bold() .font(.system(size: 10)) .opacity(0.5) @@ -296,208 +324,63 @@ struct TrackerItemView: View { .frame(width: 10) .padding(.leading, 4) } - else { - HStack { - if let bookmark = item.bookmark, bookmark.type == .server { - Image(systemName: "bookmark.fill") - .resizable() - .renderingMode(.template) - .aspectRatio(contentMode: .fit) - .frame(width: 11, height: 11, alignment: .center) - .opacity(0.5) - } - } - .frame(width: 10) - .padding(.leading, 4) - } - - HStack(alignment: .center, spacing: 0) { - if let bookmark = item.bookmark { - switch bookmark.type { - case .tracker: -// Image(systemName: "point.3.filled.connected.trianglepath.dotted") - Image("Tracker") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - case .server: - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) -// Image(systemName: "globe.americas.fill") - } - } - else if let _ = item.server { - Image("Server") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - } - .frame(width: 15) - if let bookmark = item.bookmark { - switch bookmark.type { - case .tracker: - Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) - case .server: - Text(bookmark.name).lineLimit(1).truncationMode(.tail) - } - } - else if let server = item.server { - Text(server.name ?? server.address).lineLimit(1) - - if let description = server.description, !description.isEmpty { - Text(description).foregroundStyle(.secondary).lineLimit(1).truncationMode(.tail) - } - } - - if - let server = item.server, - server.users > 0 { - Spacer() - Text("\(server.users) \(Image(systemName: "person.fill"))") - .lineLimit(1) - .foregroundStyle(.secondary) - .padding([.leading, .trailing], 4) - .padding([.top, .bottom], 2) -// .background(Capsule(style: .circular).stroke(.secondary, lineWidth: 1)) -// .opacity(0.5) - } - else if - let bookmark = item.bookmark, - bookmark.type == .tracker { - if item.loading { + switch bookmark.type { + case .tracker: + Image("Tracker") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) + if bookmark.loading { ProgressView() .padding([.leading, .trailing], 2) .controlSize(.small) } + case .server: + Image(systemName: "bookmark.fill") + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .frame(width: 11, height: 11, alignment: .center) + .opacity(0.5) + .padding(.leading, 3) + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).lineLimit(1).truncationMode(.tail) + case .temporary: Spacer() + .frame(width: 14 + 8 + 16) + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(bookmark.name).lineLimit(1).truncationMode(.tail) + if let serverDescription = bookmark.serverDescription { + Text(serverDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } } - else { - Spacer() - } + + Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.leading, CGFloat(depth * (12 + 10))) - .onChange(of: item.expanded) { - loading = false + .onChange(of: bookmark.expanded) { + guard bookmark.type == .tracker else { + return + } - if - item.expanded, - let bookmark = item.bookmark, - bookmark.type == .tracker { + if bookmark.expanded { Task { - await item.loadServers() - } - } - } - - if - item.expanded, - let servers = item.servers { - ForEach(servers, id: \.self) { serverItem in - TrackerItemView(item: serverItem, depth: self.depth + 1) - .tag(serverItem) - .deleteDisabled(!serverItem.editable) - .moveDisabled(!serverItem.editable) - } - } - } -} - -@Observable -class TrackerItem: Identifiable, Hashable { - let id: UUID = UUID() - let bookmark: Bookmark? - let server: Server? - - var editable: Bool = true - - var servers: [TrackerItem]? - - var expanded: Bool = false - var loading: Bool = false - - var displayAddress: String? { - if let s = server { - if s.port == HotlinePorts.DefaultServerPort { - return s.address - } - else { - return "\(s.address):\(s.port)" - } - } - else if let b = bookmark { - if b.port == HotlinePorts.DefaultServerPort { - return b.address - } - else { - return "\(b.address):\(b.port)" - } - } - - return nil - } - - init(bookmark: Bookmark) { - self.bookmark = bookmark - self.server = nil - self.servers = nil - - if bookmark.type == .tracker { - self.servers = [] - } - } - - init(server: Server) { - self.server = server - self.servers = nil - self.bookmark = nil - } - - static func == (lhs: TrackerItem, rhs: TrackerItem) -> Bool { - return lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(self.id) - } - - - @MainActor - func loadServers() async { - guard - let bookmark = self.bookmark, - bookmark.type == .tracker - else { - self.loading = false - return - } - - let client = HotlineTrackerClient() - - self.loading = true - - if let fetchedServers: [HotlineServer] = try? await client.fetchServers(address: bookmark.address, port: HotlinePorts.DefaultTrackerPort) { - var newItems: [TrackerItem] = [] - - for s in fetchedServers { - if let serverName = s.name { - let server = Server(name: serverName, description: s.description, address: s.address, port: Int(s.port), users: Int(s.users)) - let item = TrackerItem(server: server) - item.editable = false - newItems.append(item) + await bookmark.fetchServers() } } - - self.servers = newItems } - - self.loading = false } - } #Preview { |