diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-10-20 22:15:48 -0700 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-10-20 22:15:48 -0700 |
| commit | ede41868962ffed386b0da694d14cdfe6cfdb34f (patch) | |
| tree | f0b91edd1d9c1bbc23de525224b49d6f8e48e815 /Hotline | |
| parent | 77b3ac3c051fc4e8fa126cd21e261be28f4aad1a (diff) | |
Cleanup and a bunch of work to fix issues syncing, loading, and general bookmark states in TrackerView. Added search field (substring conjunctive filter on server/bookmark names and descriptions).
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Assets/Sounds/chat-message.aiff (renamed from Hotline/Sounds/chat-message.aiff) | bin | 4554 -> 4554 bytes | |||
| -rw-r--r-- | Hotline/Assets/Sounds/error.aiff (renamed from Hotline/Sounds/error.aiff) | bin | 4610 -> 4610 bytes | |||
| -rw-r--r-- | Hotline/Assets/Sounds/logged-in.aiff (renamed from Hotline/Sounds/logged-in.aiff) | bin | 4166 -> 4166 bytes | |||
| -rw-r--r-- | Hotline/Assets/Sounds/new-news.aiff (renamed from Hotline/Sounds/new-news.aiff) | bin | 17514 -> 17514 bytes | |||
| -rw-r--r-- | Hotline/Assets/Sounds/server-message.aiff (renamed from Hotline/Sounds/server-message.aiff) | bin | 7914 -> 7914 bytes | |||
| -rw-r--r-- | Hotline/Assets/Sounds/transfer-complete.aiff (renamed from Hotline/Sounds/transfer-complete.aiff) | bin | 21378 -> 21378 bytes | |||
| -rw-r--r-- | Hotline/Assets/Sounds/user-login.aiff (renamed from Hotline/Sounds/user-login.aiff) | bin | 6186 -> 6186 bytes | |||
| -rw-r--r-- | Hotline/Assets/Sounds/user-logout.aiff (renamed from Hotline/Sounds/user-logout.aiff) | bin | 7080 -> 7080 bytes | |||
| -rw-r--r-- | Hotline/Library/AsyncLinkPreview.swift (renamed from Hotline/Shared/AsyncLinkPreview.swift) | 0 | ||||
| -rw-r--r-- | Hotline/Library/DataAdditions.swift (renamed from Hotline/Shared/DataAdditions.swift) | 0 | ||||
| -rw-r--r-- | Hotline/Library/FileIconView.swift (renamed from Hotline/Shared/FileIconView.swift) | 0 | ||||
| -rw-r--r-- | Hotline/Library/FileImageView.swift (renamed from Hotline/Shared/FileImageView.swift) | 0 | ||||
| -rw-r--r-- | Hotline/Library/HotlinePanel.swift (renamed from Hotline/Shared/HotlinePanel.swift) | 0 | ||||
| -rw-r--r-- | Hotline/Library/NetSocket.swift (renamed from Hotline/Shared/NetSocket.swift) | 0 | ||||
| -rw-r--r-- | Hotline/Library/TextView.swift (renamed from Hotline/Shared/TextView.swift) | 0 | ||||
| -rw-r--r-- | Hotline/Library/URLAdditions.swift (renamed from Hotline/Shared/URLAdditions.swift) | 0 | ||||
| -rw-r--r-- | Hotline/Library/VisualEffectView.swift (renamed from Hotline/Shared/VisualEffectView.swift) | 0 | ||||
| -rw-r--r-- | Hotline/MacApp.swift (renamed from Hotline/Application-macOS.swift) | 26 | ||||
| -rw-r--r-- | Hotline/Models/Bookmark.swift | 100 | ||||
| -rw-r--r-- | Hotline/State/AppState.swift (renamed from Hotline/Models/ApplicationState.swift) | 12 | ||||
| -rw-r--r-- | Hotline/State/Preferences.swift (renamed from Hotline/Models/Preferences.swift) | 10 | ||||
| -rw-r--r-- | Hotline/iOSApp.swift (renamed from Hotline/Sounds/Application-iOS.swift) | 0 | ||||
| -rw-r--r-- | Hotline/macOS/HotlinePanelView.swift | 24 | ||||
| -rw-r--r-- | Hotline/macOS/TrackerView.swift | 503 |
24 files changed, 429 insertions, 246 deletions
diff --git a/Hotline/Sounds/chat-message.aiff b/Hotline/Assets/Sounds/chat-message.aiff Binary files differindex 73ea391..73ea391 100644 --- a/Hotline/Sounds/chat-message.aiff +++ b/Hotline/Assets/Sounds/chat-message.aiff diff --git a/Hotline/Sounds/error.aiff b/Hotline/Assets/Sounds/error.aiff Binary files differindex 8a9f7f6..8a9f7f6 100644 --- a/Hotline/Sounds/error.aiff +++ b/Hotline/Assets/Sounds/error.aiff diff --git a/Hotline/Sounds/logged-in.aiff b/Hotline/Assets/Sounds/logged-in.aiff Binary files differindex 5f59de0..5f59de0 100644 --- a/Hotline/Sounds/logged-in.aiff +++ b/Hotline/Assets/Sounds/logged-in.aiff diff --git a/Hotline/Sounds/new-news.aiff b/Hotline/Assets/Sounds/new-news.aiff Binary files differindex 5c56582..5c56582 100644 --- a/Hotline/Sounds/new-news.aiff +++ b/Hotline/Assets/Sounds/new-news.aiff diff --git a/Hotline/Sounds/server-message.aiff b/Hotline/Assets/Sounds/server-message.aiff Binary files differindex 152bebe..152bebe 100644 --- a/Hotline/Sounds/server-message.aiff +++ b/Hotline/Assets/Sounds/server-message.aiff diff --git a/Hotline/Sounds/transfer-complete.aiff b/Hotline/Assets/Sounds/transfer-complete.aiff Binary files differindex b22e0ae..b22e0ae 100644 --- a/Hotline/Sounds/transfer-complete.aiff +++ b/Hotline/Assets/Sounds/transfer-complete.aiff diff --git a/Hotline/Sounds/user-login.aiff b/Hotline/Assets/Sounds/user-login.aiff Binary files differindex 0dcfb6f..0dcfb6f 100644 --- a/Hotline/Sounds/user-login.aiff +++ b/Hotline/Assets/Sounds/user-login.aiff diff --git a/Hotline/Sounds/user-logout.aiff b/Hotline/Assets/Sounds/user-logout.aiff Binary files differindex 8999edc..8999edc 100644 --- a/Hotline/Sounds/user-logout.aiff +++ b/Hotline/Assets/Sounds/user-logout.aiff diff --git a/Hotline/Shared/AsyncLinkPreview.swift b/Hotline/Library/AsyncLinkPreview.swift index 89b186f..89b186f 100644 --- a/Hotline/Shared/AsyncLinkPreview.swift +++ b/Hotline/Library/AsyncLinkPreview.swift diff --git a/Hotline/Shared/DataAdditions.swift b/Hotline/Library/DataAdditions.swift index 0e9dd96..0e9dd96 100644 --- a/Hotline/Shared/DataAdditions.swift +++ b/Hotline/Library/DataAdditions.swift diff --git a/Hotline/Shared/FileIconView.swift b/Hotline/Library/FileIconView.swift index d0949fa..d0949fa 100644 --- a/Hotline/Shared/FileIconView.swift +++ b/Hotline/Library/FileIconView.swift diff --git a/Hotline/Shared/FileImageView.swift b/Hotline/Library/FileImageView.swift index 0cc50f1..0cc50f1 100644 --- a/Hotline/Shared/FileImageView.swift +++ b/Hotline/Library/FileImageView.swift diff --git a/Hotline/Shared/HotlinePanel.swift b/Hotline/Library/HotlinePanel.swift index 191e89a..191e89a 100644 --- a/Hotline/Shared/HotlinePanel.swift +++ b/Hotline/Library/HotlinePanel.swift diff --git a/Hotline/Shared/NetSocket.swift b/Hotline/Library/NetSocket.swift index a7d44ce..a7d44ce 100644 --- a/Hotline/Shared/NetSocket.swift +++ b/Hotline/Library/NetSocket.swift diff --git a/Hotline/Shared/TextView.swift b/Hotline/Library/TextView.swift index 47746e2..47746e2 100644 --- a/Hotline/Shared/TextView.swift +++ b/Hotline/Library/TextView.swift diff --git a/Hotline/Shared/URLAdditions.swift b/Hotline/Library/URLAdditions.swift index 1f25541..1f25541 100644 --- a/Hotline/Shared/URLAdditions.swift +++ b/Hotline/Library/URLAdditions.swift diff --git a/Hotline/Shared/VisualEffectView.swift b/Hotline/Library/VisualEffectView.swift index e595c55..e595c55 100644 --- a/Hotline/Shared/VisualEffectView.swift +++ b/Hotline/Library/VisualEffectView.swift diff --git a/Hotline/Application-macOS.swift b/Hotline/MacApp.swift index fe3a828..a4ff3cb 100644 --- a/Hotline/Application-macOS.swift +++ b/Hotline/MacApp.swift @@ -29,13 +29,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { // We mark CloudKit has available now since we're not waiting on // a server sync or anything. - ApplicationState.shared.cloudKitReady = true + AppState.shared.cloudKitReady = true default: print("iCloud Available") self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in print("iCloud Changed!") - ApplicationState.shared.cloudKitReady = true + AppState.shared.cloudKitReady = true guard let token = self?.cloudKitObserverToken else { return } NotificationCenter.default.removeObserver(token) @@ -48,15 +48,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { // // // We mark CloudKit has available now since we're not waiting on // // a server sync or anything. -// ApplicationState.shared.cloudKitReady = true +// AppState.shared.cloudKitReady = true // } // else { // print("iCloud Available") // // self.cloudKitObserverToken = NotificationCenter.default.addObserver(forName: NSPersistentCloudKitContainer.eventChangedNotification, object: nil, queue: OperationQueue.main) { [weak self] note in // print("iCloud Changed!") -// ApplicationState.shared.cloudKitReady = true -// +// AppState.shared.cloudKitReady = true +// // guard let token = self?.cloudKitObserverToken else { return } // NotificationCenter.default.removeObserver(token) // } @@ -77,7 +77,7 @@ struct Application: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @State private var hotlinePanel: HotlinePanel? = nil - @State private var selection: Bookmark? = nil + @State private var selection: TrackerSelection? = nil @FocusedValue(\.activeHotlineModel) private var activeHotline: Hotline? @FocusedValue(\.activeServerState) private var activeServerState: ServerState? @@ -142,25 +142,25 @@ struct Application: App { .defaultSize(width: 750, height: 700) .defaultPosition(.center) .onChange(of: activeServerState) { - ApplicationState.shared.activeServerState = activeServerState + AppState.shared.activeServerState = activeServerState } .onChange(of: activeHotline) { - ApplicationState.shared.activeHotline = activeHotline + AppState.shared.activeHotline = activeHotline } .onChange(of: activeHotline?.serverTitle) { if let hotline = activeHotline { - ApplicationState.shared.activeServerName = hotline.serverTitle + AppState.shared.activeServerName = hotline.serverTitle } } .onChange(of: activeHotline?.bannerImage) { withAnimation { - ApplicationState.shared.activeServerBanner = activeHotline?.bannerImage + AppState.shared.activeServerBanner = activeHotline?.bannerImage } } .onChange(of: activeHotline) { - ApplicationState.shared.activeHotline = activeHotline + AppState.shared.activeHotline = activeHotline if let hotline = activeHotline { - ApplicationState.shared.activeServerName = hotline.serverTitle + AppState.shared.activeServerName = hotline.serverTitle } } .commands { @@ -269,7 +269,7 @@ struct Application: App { .defaultPosition(.center) } - func connect(to item: Bookmark) { + func connect(to item: TrackerSelection) { if let server = item.server { openWindow(id: "server", value: server) } diff --git a/Hotline/Models/Bookmark.swift b/Hotline/Models/Bookmark.swift index 733a6eb..394f9f6 100644 --- a/Hotline/Models/Bookmark.swift +++ b/Hotline/Models/Bookmark.swift @@ -4,7 +4,27 @@ import SwiftData enum BookmarkType: String, Codable { case tracker = "tracker" case server = "server" - case temporary = "temporary" +} + +struct BookmarkServer: Hashable, Identifiable { + let id = UUID() + let name: String? + let address: String + let port: Int + let description: String? + let users: Int + + var server: Server { + Server(name: self.name, description: self.description, address: self.address, port: self.port, users: self.users) + } + + init(server: Server) { + self.name = server.name + self.address = server.address + self.port = server.port + self.description = server.description + self.users = server.users + } } @Model @@ -18,29 +38,10 @@ final class Bookmark { @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 - - @Attribute(.ephemeral) - var serverUserCount: Int? = nil - - @Transient - var servers: [Bookmark] = [] - - func hash(into hasher: inout Hasher) { - - } - + @Transient var displayAddress: String { switch self.type { @@ -51,8 +52,8 @@ final class Bookmark { else { return "\(self.address):\(String(self.port))" } - - case .server, .temporary: + + case .server: if self.port == HotlinePorts.DefaultServerPort { return self.address } @@ -67,9 +68,9 @@ final class Bookmark { 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) + + case .server: + return Server(name: self.name, description: nil, address: self.address, port: self.port, login: self.login, password: self.password) } } @@ -84,22 +85,11 @@ final class Bookmark { 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 - self.serverUserCount = server.users - } - + init?(fileData: Data, name: String? = nil) { guard fileData.count <= 2000 else { return nil @@ -248,11 +238,6 @@ final class Bookmark { } 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. @@ -348,32 +333,23 @@ final class Bookmark { } } - func fetchServers() async { + func fetchServers() async -> [BookmarkServer] { guard self.type == .tracker else { - // self.loading = false - return - } - - DispatchQueue.main.sync { - self.loading = true + return [] } - - var fetchedBookmarks: [Bookmark] = [] - + + var fetchedBookmarks: [BookmarkServer] = [] + 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)) + fetchedBookmarks.append(BookmarkServer(server: server)) } } } - - let newServers = fetchedBookmarks - DispatchQueue.main.sync { - self.servers = newServers - self.loading = false - } + + return fetchedBookmarks } } diff --git a/Hotline/Models/ApplicationState.swift b/Hotline/State/AppState.swift index 6e077c5..11bae74 100644 --- a/Hotline/Models/ApplicationState.swift +++ b/Hotline/State/AppState.swift @@ -1,8 +1,16 @@ import SwiftUI +extension EnvironmentValues { + @Entry var appState: AppState = AppState.shared +} + @Observable -final class ApplicationState { - static let shared = ApplicationState() +final class AppState { + static let shared = AppState() + + private init() { + + } var activeHotline: Hotline? = nil var activeServerState: ServerState? = nil diff --git a/Hotline/Models/Preferences.swift b/Hotline/State/Preferences.swift index 207ce91..fef2b9a 100644 --- a/Hotline/Models/Preferences.swift +++ b/Hotline/State/Preferences.swift @@ -1,5 +1,9 @@ import SwiftUI +extension EnvironmentValues { + @Entry var preferences: Prefs = Prefs.shared +} + enum PrefsKeys: String { case username = "username" case userIconID = "user icon id" @@ -22,7 +26,9 @@ enum PrefsKeys: String { @Observable class Prefs { - init() { + static let shared = Prefs() + + private init() { UserDefaults.standard.register(defaults:[ PrefsKeys.username.rawValue: "guest", PrefsKeys.userIconID.rawValue: 191, @@ -61,8 +67,6 @@ class Prefs { self.showBannerToolbar = UserDefaults.standard.bool(forKey: PrefsKeys.showBannerToolbar.rawValue) self.showJoinLeaveMessages = UserDefaults.standard.bool(forKey: PrefsKeys.showJoinLeaveMessages.rawValue) } - - public static let shared = Prefs() var username: String { didSet { UserDefaults.standard.set(self.username, forKey: PrefsKeys.username.rawValue) } diff --git a/Hotline/Sounds/Application-iOS.swift b/Hotline/iOSApp.swift index 2fb6ad2..2fb6ad2 100644 --- a/Hotline/Sounds/Application-iOS.swift +++ b/Hotline/iOSApp.swift diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index dc58698..fd43c15 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -6,7 +6,7 @@ struct HotlinePanelView: View { var body: some View { VStack(spacing: 0) { - Image(nsImage: ApplicationState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) + Image(nsImage: AppState.shared.activeServerBanner ?? NSImage(named: "Default Banner")!) .interpolation(.high) .resizable() .scaledToFill() @@ -36,7 +36,7 @@ struct HotlinePanelView: View { .help("Hotline Servers") Button { - ApplicationState.shared.activeServerState?.selection = .chat + AppState.shared.activeServerState?.selection = .chat } label: { Image("Section Chat") @@ -45,11 +45,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Public Chat") Button { - ApplicationState.shared.activeServerState?.selection = .board + AppState.shared.activeServerState?.selection = .board } label: { Image("Section Board") @@ -58,11 +58,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Message Board") Button { - ApplicationState.shared.activeServerState?.selection = .news + AppState.shared.activeServerState?.selection = .news } label: { Image("Section News") @@ -71,11 +71,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil || (ApplicationState.shared.activeHotline?.serverVersion ?? 0) < 151) + .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) .help("News") Button { - ApplicationState.shared.activeServerState?.selection = .files + AppState.shared.activeServerState?.selection = .files } label: { Image("Section Files") @@ -84,14 +84,14 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Files") Spacer() - if ApplicationState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { + if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - ApplicationState.shared.activeServerState?.selection = .accounts + AppState.shared.activeServerState?.selection = .accounts } label: { Image("Section Users") @@ -100,7 +100,7 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(ApplicationState.shared.activeServerState == nil) + .disabled(AppState.shared.activeServerState == nil) .help("Accounts") } diff --git a/Hotline/macOS/TrackerView.swift b/Hotline/macOS/TrackerView.swift index 0fac8b0..2fbce85 100644 --- a/Hotline/macOS/TrackerView.swift +++ b/Hotline/macOS/TrackerView.swift @@ -3,6 +3,18 @@ import SwiftData import Foundation import UniformTypeIdentifiers +enum TrackerSelection: Hashable { + case bookmark(Bookmark) + case bookmarkServer(BookmarkServer) + + var server: Server? { + switch self { + case .bookmark(let b): return b.server + case .bookmarkServer(let t): return t.server + } + } +} + struct TrackerView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @@ -16,22 +28,78 @@ struct TrackerView: View { @State private var fileDropActive = false @State private var bookmarkExportActive = false @State private var bookmarkExport: BookmarkDocument? = nil - + @State private var expandedTrackers: Set<Bookmark> = [] + @State private var trackerServers: [Bookmark: [BookmarkServer]] = [:] + @State private var loadingTrackers: Set<Bookmark> = [] + @State private var searchText: String = "" + @Query(sort: \Bookmark.order) private var bookmarks: [Bookmark] - @Binding var selection: Bookmark? + @Binding var selection: TrackerSelection? + + private var filteredBookmarks: [Bookmark] { + guard !self.searchText.isEmpty else { + return self.bookmarks + } + + let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) + + return self.bookmarks.filter { bookmark in + // Always show tracker bookmarks (filter only their servers) + if bookmark.type == .tracker { + return true + } + + // Filter server bookmarks by search text + return self.bookmarkMatchesSearch(bookmark, searchWords: searchWords) + } + } + + private func bookmarkMatchesSearch(_ bookmark: Bookmark, searchWords: [String]) -> Bool { + let searchableText = "\(bookmark.name) \(bookmark.address)".lowercased() + + // All search words must match + return searchWords.allSatisfy { word in + searchableText.contains(word) + } + } + + private func filteredServers(for bookmark: Bookmark) -> [BookmarkServer] { + let servers = self.trackerServers[bookmark] ?? [] + + guard !self.searchText.isEmpty else { + return servers + } + + let searchWords = self.searchText.lowercased().split(separator: " ").map(String.init) + + return servers.filter { server in + let searchableText = "\(server.name ?? "") \(server.address) \(server.description ?? "")".lowercased() + + // All search words must match + return searchWords.allSatisfy { word in + searchableText.contains(word) + } + } + } 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) + ForEach(filteredBookmarks, id: \.self) { bookmark in + TrackerItemView( + bookmark: bookmark, + isExpanded: self.expandedTrackers.contains(bookmark), + isLoading: self.loadingTrackers.contains(bookmark) + ) { + self.toggleExpanded(for: bookmark) + } + .tag(TrackerSelection.bookmark(bookmark)) + + if bookmark.type == .tracker && self.expandedTrackers.contains(bookmark) { + ForEach(self.filteredServers(for: bookmark), id: \.self) { trackedServer in + TrackerBookmarkServerView(server: trackedServer) .moveDisabled(true) .deleteDisabled(true) - .tag(trackedServer) + .tag(TrackerSelection.bookmarkServer(trackedServer)) } } } @@ -43,15 +111,22 @@ struct TrackerView: View { } } .onDeleteCommand { - if let bookmark = selection, - bookmark.type != .temporary { + switch self.selection { + case .bookmark(let bookmark): Bookmark.delete(bookmark, context: modelContext) + default: + break } + +// if let bookmark = selection, +// bookmark.type != .temporary { +// Bookmark.delete(bookmark, context: modelContext) +// } } .environment(\.defaultMinListRowHeight, 34) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) - .onChange(of: ApplicationState.shared.cloudKitReady) { + .onChange(of: AppState.shared.cloudKitReady) { if attemptedPrepopulate { print("Tracker: Already attempted to prepopulate bookmarks") return @@ -67,53 +142,13 @@ struct TrackerView: View { .onAppear { // Bookmark.deleteAll(context: modelContext) } - .contextMenu(forSelectionType: Bookmark.self) { items in + .contextMenu(forSelectionType: TrackerSelection.self) { items in if let item = items.first { - if item.type == .temporary { - Button { - 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 item.type == .tracker || item.type == .server { - Divider() - - if item.type == .tracker { - Button { - trackerSheetBookmark = item - } label: { - Label("Edit Tracker...", systemImage: "pencil") - } - } - - if item.type == .server { - Button { - bookmarkExport = BookmarkDocument(bookmark: item) - bookmarkExportActive = true - } label: { - Label("Export Bookmark...", systemImage: "bookmark.square") - } - } - - Divider() - - Button { - Bookmark.delete(item, context: modelContext) - } label: { - Label(item.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") - } + switch item { + case .bookmark(let bookmark): + self.bookmarkContextMenu(bookmark) + case .bookmarkServer(let server): + self.bookmarkServerContextMenu(server) } } } primaryAction: { items in @@ -121,17 +156,37 @@ struct TrackerView: View { return } - if clickedItem.type == .tracker { - if NSEvent.modifierFlags.contains(.option) { - trackerSheetBookmark = clickedItem + switch clickedItem { + case .bookmark(let bookmark): + if bookmark.type == .server { + if let s = bookmark.server { + openWindow(id: "server", value: s) + } } - else { - clickedItem.expanded.toggle() + else if bookmark.type == .tracker { + if NSEvent.modifierFlags.contains(.option) { + trackerSheetBookmark = bookmark + } + else { + self.toggleExpanded(for: bookmark) + } } + + case .bookmarkServer(let bookmarkServer): + openWindow(id: "server", value: bookmarkServer.server) } - else if let server = clickedItem.server { - openWindow(id: "server", value: server) - } + +// if clickedItem.type == .tracker { +// if NSEvent.modifierFlags.contains(.option) { +// trackerSheetBookmark = clickedItem +// } +// else { +// clickedItem.expanded.toggle() +// } +// } +// else if let server = clickedItem.server { +// openWindow(id: "server", value: server) +// } } .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in switch result { @@ -145,21 +200,41 @@ struct TrackerView: View { bookmarkExportActive = false }, onCancellation: {}) .onKeyPress(.rightArrow) { - if - let bookmark = selection, - bookmark.type == .tracker { - bookmark.expanded = true - return .handled + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.expandedTrackers.insert(bookmark) + return .handled + } + default: + break } + +// if +// let bookmark = selection, +// bookmark.type == .tracker { +// bookmark.expanded = true +// return .handled +// } return .ignored } .onKeyPress(.leftArrow) { - if - let bookmark = selection, - bookmark.type == .tracker { - bookmark.expanded = false - return .handled + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.expandedTrackers.remove(bookmark) + return .handled + } + default: + break } + +// if +// let bookmark = selection, +// bookmark.type == .tracker { +// bookmark.expanded = false +// return .handled +// } return .ignored } .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in @@ -198,20 +273,26 @@ struct TrackerView: View { .navigationTitle("Servers") .toolbar { ToolbarItem(placement: .navigation) { - Image("Hotline") - .resizable() - .renderingMode(.template) - .scaledToFit() - .foregroundColor(Color(hex: 0xE10000)) - .frame(width: 9) - .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + let image = Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 9) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + +// if #available(macOS 26, *) { +// image.sharedBackgroundVisibility(.hidden) +// } else { + image +// } } ToolbarItem(placement: .primaryAction) { Button { - refreshing = true - refresh() - refreshing = false + self.refreshing = true + self.refresh() + self.refreshing = false } label: { Label("Refresh", systemImage: "arrow.clockwise") } @@ -242,38 +323,142 @@ struct TrackerView: View { openWindow(id: "server", value: s) } }) + .searchable(text: $searchText, placement: .automatic, prompt: "Search") + } + + @ViewBuilder + func bookmarkServerContextMenu(_ server: BookmarkServer) -> some View { + Button { + let newBookmark = Bookmark(type: .server, name: server.name ?? server.address, address: server.address, port: server.port, login: nil, password: nil) + Bookmark.add(newBookmark, context: modelContext) + } label: { + Label("Bookmark", systemImage: "bookmark") + } + + Divider() + + Button { + NSPasteboard.general.clearContents() + let displayAddress = server.port == HotlinePorts.DefaultServerPort ? + server.address : "\(server.address):\(server.port)" + NSPasteboard.general.setString(displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + } + + @ViewBuilder + func bookmarkContextMenu(_ bookmark: Bookmark) -> some View { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(bookmark.displayAddress, forType: .string) + } label: { + Label("Copy Address", systemImage: "doc.on.doc") + } + + Divider() + + if bookmark.type == .tracker { + Button { + trackerSheetBookmark = bookmark + } label: { + Label("Edit Tracker...", systemImage: "pencil") + } + } + + if bookmark.type == .server { + Button { + bookmarkExport = BookmarkDocument(bookmark: bookmark) + bookmarkExportActive = true + } label: { + Label("Export Bookmark...", systemImage: "bookmark.square") + } + } + + Divider() + + Button { + Bookmark.delete(bookmark, context: modelContext) + } label: { + Label(bookmark.type == .tracker ? "Delete Tracker" : "Delete Bookmark", systemImage: "trash") + } } + 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() + if let trackerSelection = self.selection { + switch trackerSelection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, just refresh the servers + Task { + await self.fetchServers(for: bookmark) + } + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } } + return + default: + break } - return } - + // Otherwise refresh/expand all trackers. for bookmark in self.bookmarks { if bookmark.type == .tracker { - if !bookmark.expanded { - bookmark.expanded = true - } - else { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, just refresh the servers Task { - await bookmark.fetchServers() + await self.fetchServers(for: bookmark) } + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) } } } } + + func toggleExpanded(for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + + if self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + } else { + self.expandedTrackers.insert(bookmark) + Task { + await self.fetchServers(for: bookmark) + } + } + } + + func setExpanded(_ expanded: Bool, for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + + if expanded && !self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.insert(bookmark) + Task { + await self.fetchServers(for: bookmark) + } + } else if !expanded && self.expandedTrackers.contains(bookmark) { + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + } + } + + private func fetchServers(for bookmark: Bookmark) async { + self.loadingTrackers.insert(bookmark) + let servers = await bookmark.fetchServers() + await MainActor.run { + self.trackerServers[bookmark] = servers + self.loadingTrackers.remove(bookmark) + } + } } struct TrackerBookmarkSheet: View { @@ -359,18 +544,60 @@ struct TrackerBookmarkSheet: View { } } +struct TrackerBookmarkServerView: View { + let server: BookmarkServer + + var body: some View { + HStack(alignment: .center, spacing: 6) { + Spacer() + .frame(width: 14 + 8 + 16) + Image("Server") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16, alignment: .center) + Text(self.server.name ?? "Server").lineLimit(1).truncationMode(.tail) + if let serverDescription = self.server.description { + Text(serverDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 0) + if self.server.users > 0 { + Text(String(self.server.users)) + .foregroundStyle(.secondary) + .lineLimit(1) + + Circle() + .fill(.fileComplete) + .frame(width: 7, height: 7) + .keyframeAnimator(initialValue: 1.0, repeating: true) { content, opacity in + content.opacity(opacity) + } keyframes: { _ in + CubicKeyframe(1.0, duration: 2.0) // Stay visible for 1 second + CubicKeyframe(0.75, duration: 0.5) // Fade out quickly + CubicKeyframe(1.0, duration: 0.5) // Fade in quickly + } + .padding(.trailing, 6) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + struct TrackerItemView: View { let bookmark: Bookmark - - @State private var onlineAnimationMaxState: Bool = true - + let isExpanded: Bool + let isLoading: Bool + let onToggleExpanded: () -> Void + var body: some View { HStack(alignment: .center, spacing: 6) { if bookmark.type == .tracker { Button { - bookmark.expanded.toggle() + self.onToggleExpanded() } label: { - Text(Image(systemName: bookmark.expanded ? "chevron.down" : "chevron.right")) + Text(Image(systemName: self.isExpanded ? "chevron.down" : "chevron.right")) .bold() .font(.system(size: 10)) .opacity(0.5) @@ -381,7 +608,7 @@ struct TrackerItemView: View { .padding(.leading, 4) .padding(.trailing, 2) } - + switch bookmark.type { case .tracker: Image("Tracker") @@ -389,7 +616,7 @@ struct TrackerItemView: View { .scaledToFit() .frame(width: 16, height: 16, alignment: .center) Text(bookmark.name).bold().lineLimit(1).truncationMode(.tail) - if bookmark.loading { + if isLoading { ProgressView() .padding([.leading, .trailing], 2) .controlSize(.small) @@ -410,58 +637,26 @@ struct TrackerItemView: View { .frame(width: 16, height: 16, alignment: .center) Text(bookmark.name).lineLimit(1).truncationMode(.tail) Spacer(minLength: 0) - 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) - } - Spacer(minLength: 0) - if let serverUserCount = bookmark.serverUserCount, - serverUserCount > 0 { - Text(String(serverUserCount)) - .foregroundStyle(.secondary) - .lineLimit(1) - - Circle() - .fill(.fileComplete) - .frame(width: 7, height: 7) - .opacity(onlineAnimationMaxState ? 1.0 : 0.2) - .onAppear { - withAnimation(.easeInOut(duration: 1.0).repeatForever()) { - onlineAnimationMaxState.toggle() - } - } - .padding(.trailing, 6) - } } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onChange(of: bookmark.expanded) { - guard bookmark.type == .tracker else { - return - } - - if bookmark.expanded { - Task { - await bookmark.fetchServers() - } - } - } +// .onChange(of: self.isExpanded) { +// guard bookmark.type == .tracker else { +// return +// } +// +// if self.isExpanded { +// Task { +// await bookmark.fetchServers() +// } +// } +// } } } #if DEBUG private struct TrackerViewPreview: View { - @State var selection: Bookmark? = nil + @State var selection: TrackerSelection? = nil var body: some View { TrackerView(selection: $selection) |