diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-11-07 16:16:31 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-11-07 16:16:31 -0800 |
| commit | 6afa5551add4541f376867b3d6527df9a0f793f3 (patch) | |
| tree | a1fbdb6c0a71f621a01c2821fe5d340d5853f1ca /Hotline/macOS | |
| parent | e1566598c96601ebcf3229aab22fc7a593ee1904 (diff) | |
Further UI tweraks. Transfers button in toolbar. Better empty states in places. Fix race condition with transactions ids (yikes)!
Diffstat (limited to 'Hotline/macOS')
| -rw-r--r-- | Hotline/macOS/HotlinePanelView.swift | 12 | ||||
| -rw-r--r-- | Hotline/macOS/News/NewsView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/ServerBookmarkSheet.swift | 70 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerBookmarkServerView.swift | 4 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerBookmarkSheet.swift | 4 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerItemView.swift | 4 | ||||
| -rw-r--r-- | Hotline/macOS/Trackers/TrackerView.swift | 480 |
7 files changed, 572 insertions, 4 deletions
diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 7819c2f..81c24b7 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -119,6 +119,18 @@ struct HotlinePanelView: View { .disabled(self.activeServerState == nil) .help("Accounts") } + + Button { + self.openWindow(id: "transfers") + } + label: { + Image("Section Transfers") + .resizable() + .scaledToFit() + } + .buttonStyle(.plain) + .frame(width: 20, height: 20) + .help("File Transfers") SettingsLink(label: { Image("Section Settings") diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index a07a441..976a985 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -138,7 +138,7 @@ struct NewsView: View { ContentUnavailableView { Label("No News", systemImage: "newspaper") } description: { - Text("This server has no newsgroups") + Text("This server has not created any newsgroups yet") } } diff --git a/Hotline/macOS/Trackers/ServerBookmarkSheet.swift b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift new file mode 100644 index 0000000..6ad1657 --- /dev/null +++ b/Hotline/macOS/Trackers/ServerBookmarkSheet.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct ServerBookmarkSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext + + @State private var bookmark: Bookmark + @State private var serverName: String = "" + @State private var serverAddress: String = "" + @State private var serverLogin: String = "" + @State private var serverPassword: String = "" + + init(_ editingBookmark: Bookmark) { + _bookmark = .init(initialValue: editingBookmark) + _serverName = .init(initialValue: editingBookmark.name) + _serverAddress = .init(initialValue: editingBookmark.displayAddress) + _serverLogin = .init(initialValue: editingBookmark.login ?? "") + _serverPassword = .init(initialValue: editingBookmark.password ?? "") + } + + var body: some View { + Form { + Section { + TextField(text: $serverName) { + Text("Name") + } + } + + Section { + TextField(text: $serverAddress) { + Text("Address") + } + TextField(text: $serverLogin, prompt: Text("Optional")) { + Text("Login") + } + SecureField(text: $serverPassword, prompt: Text("Optional")) { + Text("Password") + } + } + } + .formStyle(.grouped) + .frame(width: 350) + .fixedSize(horizontal: true, vertical: true) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let displayName = self.serverName.trimmingCharacters(in: .whitespacesAndNewlines) + let (host, port) = Server.parseServerAddressAndPort(self.serverAddress) + let login = self.serverLogin.trimmingCharacters(in: .whitespacesAndNewlines) + let password = self.serverPassword + + if !displayName.isEmpty && !host.isEmpty { + self.bookmark.name = displayName + self.bookmark.address = host + self.bookmark.port = port + self.bookmark.login = login.isEmpty ? nil : login + self.bookmark.password = password.isEmpty ? nil : password + + self.dismiss() + } + } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + } + } +} diff --git a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift index dc42ea9..93aea15 100644 --- a/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift +++ b/Hotline/macOS/Trackers/TrackerBookmarkServerView.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerBookmarkServerView: View { let server: BookmarkServer @@ -35,4 +37,4 @@ struct TrackerBookmarkServerView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } -}
\ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift index 4943016..d66a466 100644 --- a/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift +++ b/Hotline/macOS/Trackers/TrackerBookmarkSheet.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerBookmarkSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -116,4 +118,4 @@ struct TrackerBookmarkSheet: View { } } } -}
\ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerItemView.swift b/Hotline/macOS/Trackers/TrackerItemView.swift index 59caad5..6e62518 100644 --- a/Hotline/macOS/Trackers/TrackerItemView.swift +++ b/Hotline/macOS/Trackers/TrackerItemView.swift @@ -1,3 +1,5 @@ +import SwiftUI + struct TrackerItemView: View { let bookmark: Bookmark let isExpanded: Bool @@ -70,4 +72,4 @@ struct TrackerItemView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } -}
\ No newline at end of file +} diff --git a/Hotline/macOS/Trackers/TrackerView.swift b/Hotline/macOS/Trackers/TrackerView.swift new file mode 100644 index 0000000..ddc0a67 --- /dev/null +++ b/Hotline/macOS/Trackers/TrackerView.swift @@ -0,0 +1,480 @@ +import SwiftUI +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 + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.modelContext) private var modelContext + + @State private var refreshing = false + @State private var trackerSheetPresented: Bool = false + @State private var trackerSheetBookmark: Bookmark? = nil + @State private var serverSheetBookmark: Bookmark? = nil + @State private var attemptedPrepopulate: Bool = false + @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 fetchTasks: [Bookmark: Task<Void, Never>] = [:] + @State private var searchText: String = "" + @State private var isSearching = false + + @Query(sort: \Bookmark.order) private var bookmarks: [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] ?? [] + print("TrackerView.filteredServers: Looking up servers for \(bookmark.name), found \(servers.count) servers") + + 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(filteredBookmarks, id: \.self) { bookmark in + TrackerItemView( + bookmark: bookmark, + isExpanded: self.expandedTrackers.contains(bookmark), + isLoading: self.loadingTrackers.contains(bookmark), + count: self.trackerServers[bookmark]?.count ?? 0 + ) { + 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(TrackerSelection.bookmarkServer(trackedServer)) + .padding(.leading, 16 + 8 + 10) + } + } + } + .onMove { movedIndexes, destinationIndex in + Bookmark.move(movedIndexes, to: destinationIndex, context: modelContext) + } + .onDelete { deletedIndexes in + Bookmark.delete(at: deletedIndexes, context: modelContext) + } + } + .onDeleteCommand { + 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: AppState.shared.cloudKitReady) { + if attemptedPrepopulate { + print("Tracker: Already attempted to prepopulate bookmarks") + return + } + + print("Tracker: Prepopulating bookmarks") + + attemptedPrepopulate = true + + // Make sure default bookmarks are there when empty. + Bookmark.populateDefaults(context: modelContext) + } + .onAppear { +// Bookmark.deleteAll(context: modelContext) + } + .contextMenu(forSelectionType: TrackerSelection.self) { items in + if let item = items.first { + switch item { + case .bookmark(let bookmark): + self.bookmarkContextMenu(bookmark) + case .bookmarkServer(let server): + self.bookmarkServerContextMenu(server) + } + } + } primaryAction: { items in + guard let clickedItem = items.first else { + return + } + + switch clickedItem { + case .bookmark(let bookmark): + if bookmark.type == .server { + if let s = bookmark.server { + openWindow(id: "server", value: s) + } + } + 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) + } + } + .fileExporter(isPresented: $bookmarkExportActive, document: bookmarkExport, contentTypes: [.data], defaultFilename: "\(bookmarkExport?.bookmark.name ?? "Hotline Bookmark").hlbm", onCompletion: { result in + switch result { + case .success(let fileURL): + print("Hotline Bookmark: Successfully exported:", fileURL) + case .failure(let err): + print("Hotline Bookmark: Failed to export:", err) + } + + bookmarkExport = nil + bookmarkExportActive = false + }, onCancellation: {}) + .onKeyPress(.rightArrow) { + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.setExpanded(true, for: bookmark) + return .handled + } + default: + break + } + + return .ignored + } + .onKeyPress(.leftArrow) { + switch self.selection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + self.setExpanded(false, for: bookmark) + return .handled + } + default: + break + } + + return .ignored + } + .onDrop(of: [UTType.fileURL], isTargeted: $fileDropActive) { providers, dropPoint in + for provider in providers { + let _ = provider.loadDataRepresentation(for: UTType.fileURL) { dataRepresentation, err in + // HOTLINE CREATOR CODE: 1213484099 + // HOTLINE BOOKMARK TYPE CODE: 1213489773 + + if let filePathData = dataRepresentation, + let filePath = String(data: filePathData, encoding: .utf8), + let fileURL = URL(string: filePath) { + + print("Hotline Bookmark: Dropped from ", fileURL.path(percentEncoded: false)) + + DispatchQueue.main.async { + if let newBookmark = Bookmark(fileURL: fileURL) { + print("Hotline Bookmark: Added bookmark.") + Bookmark.add(newBookmark, context: modelContext) + } + else { + print("Hotline Bookmark: Failed to parse.") + } + } + } + } + } + + return true + } + .sheet(item: $trackerSheetBookmark) { item in + TrackerBookmarkSheet(item) + } + .sheet(isPresented: $trackerSheetPresented) { + TrackerBookmarkSheet() + } + .sheet(item: $serverSheetBookmark) { item in + ServerBookmarkSheet(item) + } + .navigationTitle("Servers") + .toolbar { + if #available(macOS 26.0, *) { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } + .sharedBackgroundVisibility(.hidden) + } + else { + ToolbarItem(placement: .navigation) { + self.hotlineLogoImage + } + } + + ToolbarItem(placement: .primaryAction) { + Button { + self.refreshing = true + self.refresh() + self.refreshing = false + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(refreshing) + .help("Refresh Trackers") + } + + ToolbarItem(placement: .primaryAction) { + Button { + trackerSheetPresented = true + } label: { + Label("Add Tracker", systemImage: "point.3.filled.connected.trianglepath.dotted") + } + .help("Add Tracker") + } + + ToolbarItem(placement: .primaryAction) { + Button { + openWindow(id: "server") + } label: { + Label("Connect to Server", systemImage: "globe.americas.fill") + } + .help("Connect to Server") + } + } + .onOpenURL(perform: { url in + if let s = Server(url: url) { + openWindow(id: "server", value: s) + } + }) + .searchable(text: $searchText, isPresented: $isSearching, placement: .automatic, prompt: "Search") + .background(Button("", action: { isSearching = true }).keyboardShortcut("f").hidden()) + } + + private var hotlineLogoImage: some View { + Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 9) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + } + + @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 { + serverSheetBookmark = bookmark + } label: { + Label("Edit Bookmark...", systemImage: "pencil") + } + + Button { + bookmarkExport = BookmarkDocument(bookmark: bookmark) + bookmarkExportActive = true + } label: { + Label("Export Bookmark...", systemImage: "square.and.arrow.down") + } + } + + 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 trackerSelection = self.selection { + switch trackerSelection { + case .bookmark(let bookmark): + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } + return + } + break + default: + break + } + } + + // Otherwise refresh/expand all trackers. + for bookmark in self.bookmarks { + if bookmark.type == .tracker { + if self.expandedTrackers.contains(bookmark) { + // Already expanded, cancel old fetch and start new one + self.fetchTasks[bookmark]?.cancel() + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else { + // Not expanded, expand it (which also fetches) + self.setExpanded(true, for: bookmark) + } + } + } + } + + func toggleExpanded(for bookmark: Bookmark) { + guard bookmark.type == .tracker else { return } + self.setExpanded(!self.expandedTrackers.contains(bookmark), 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) + let task = Task { + await self.fetchServers(for: bookmark) + } + self.fetchTasks[bookmark] = task + } else if !expanded && self.expandedTrackers.contains(bookmark) { + // Cancel ongoing fetch and clear data + self.fetchTasks[bookmark]?.cancel() + self.fetchTasks[bookmark] = nil + self.expandedTrackers.remove(bookmark) + self.trackerServers[bookmark] = nil + self.loadingTrackers.remove(bookmark) + } + } + + private func fetchServers(for bookmark: Bookmark) async { + print("TrackerView.fetchServers: Starting fetch for bookmark: \(bookmark.name)") + self.loadingTrackers.insert(bookmark) + let servers = await bookmark.fetchServers() + print("TrackerView.fetchServers: Got \(servers.count) servers from bookmark.fetchServers()") + await MainActor.run { + print("TrackerView.fetchServers: Assigning \(servers.count) servers to trackerServers[\(bookmark.name)]") + self.trackerServers[bookmark] = servers + self.loadingTrackers.remove(bookmark) + self.fetchTasks[bookmark] = nil // Clean up completed task + print("TrackerView.fetchServers: trackerServers now has \(self.trackerServers.count) entries") + print("TrackerView.fetchServers: Verification - trackerServers[\(bookmark.name)] now has \(self.trackerServers[bookmark]?.count ?? -1) servers") + } + } +} + +#if DEBUG +private struct TrackerViewPreview: View { + @State var selection: TrackerSelection? = nil + + var body: some View { + TrackerView(selection: $selection) + } +} + +#Preview { + TrackerViewPreview() +} +#endif |