diff options
Diffstat (limited to 'Hotline/macOS')
| -rw-r--r-- | Hotline/macOS/Accounts/AccountManagerView.swift | 44 | ||||
| -rw-r--r-- | Hotline/macOS/Board/MessageBoardEditorView.swift | 14 | ||||
| -rw-r--r-- | Hotline/macOS/Board/MessageBoardView.swift | 6 | ||||
| -rw-r--r-- | Hotline/macOS/Chat/ChatView.swift | 77 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FileDetailsView.swift | 6 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FileItemView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilePreviewImageView.swift | 11 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilePreviewQuickLookView.swift | 131 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilePreviewTextView.swift | 9 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FilesView.swift | 103 | ||||
| -rw-r--r-- | Hotline/macOS/Files/FolderItemView.swift | 6 | ||||
| -rw-r--r-- | Hotline/macOS/HotlinePanelView.swift | 52 | ||||
| -rw-r--r-- | Hotline/macOS/MessageView.swift | 10 | ||||
| -rw-r--r-- | Hotline/macOS/News/NewsEditorView.swift | 19 | ||||
| -rw-r--r-- | Hotline/macOS/News/NewsItemView.swift | 8 | ||||
| -rw-r--r-- | Hotline/macOS/News/NewsView.swift | 12 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 165 | ||||
| -rw-r--r-- | Hotline/macOS/Settings/IconSettingsView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/TransfersView.swift | 169 |
19 files changed, 633 insertions, 213 deletions
diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index 57682cc..c45ba18 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,7 +1,7 @@ import SwiftUI struct AccountManagerView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var accounts: [HotlineAccount] = [] @State private var selection: HotlineAccount? @@ -43,7 +43,7 @@ struct AccountManagerView: View { .alternatingRowBackgrounds(.enabled) .task { if loading { - accounts = await model.getAccounts() + accounts = (try? await model.getAccounts()) ?? [] loading = false } } @@ -243,47 +243,49 @@ struct AccountManagerView: View { // .padding() Spacer() - - Button("Save"){ + + Button(action: { guard let selection else { return } - + // Update existing account if selection.persisted == true { if pendingPassword == placeholderPassword { Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) + try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) } } else { Task { @MainActor in - model.client.sendSetUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) + try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) } } } else { // Create new existing account Task { @MainActor in - model.client.sendCreateUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) + try? await model.createUser(name: pendingName, login: pendingLogin, password: pendingPassword, access: pendingAccess.rawValue) } self.selection?.password = pendingPassword pendingPassword = placeholderPassword } - + var account = HotlineAccount(pendingName, pendingLogin, pendingAccess) account.persisted = true account.password = placeholderPassword - + accounts = accounts.filter { $0.persisted == true && $0.login != selection.login } - + // Add new account to list accounts.append(account) - + // Re-sort accounts accounts.sort { $0.login < $1.login } self.selection = account - } + }, label: { + Text("Save") + }) .controlSize(.large) .frame(minWidth: 75) .keyboardShortcut(.defaultAction) @@ -349,23 +351,25 @@ struct AccountManagerView: View { } ToolbarItem(placement: .primaryAction) { - Button("Delete") { + Button(action: { guard let userToDelete = toDelete else { return } - + self.toDelete = nil self.selection = nil - + if userToDelete.persisted { Task { @MainActor in - model.client.sendDeleteUser(login: userToDelete.login) + try? await model.deleteUser(login: userToDelete.login) } } - + accounts = accounts.filter { $0.login != userToDelete.login } - - } + + }, label: { + Text("Delete") + }) } } } diff --git a/Hotline/macOS/Board/MessageBoardEditorView.swift b/Hotline/macOS/Board/MessageBoardEditorView.swift index 474384e..ea1754c 100644 --- a/Hotline/macOS/Board/MessageBoardEditorView.swift +++ b/Hotline/macOS/Board/MessageBoardEditorView.swift @@ -8,7 +8,7 @@ struct MessageBoardEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var text: String = "" @State private var sending: Bool = false @@ -17,11 +17,11 @@ struct MessageBoardEditorView: View { func sendPost() async { sending = true - + let cleanedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - - model.postToMessageBoard(text: cleanedText) - let _ = await model.getMessageBoard() + + try? await model.postToMessageBoard(text: cleanedText) + let _ = try? await model.getMessageBoard() // let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) // if success { @@ -69,9 +69,9 @@ struct MessageBoardEditorView: View { else { Button { sending = true - model.postToMessageBoard(text: text) Task { - let _ = await model.getMessageBoard() + try? await model.postToMessageBoard(text: text) + let _ = try? await model.getMessageBoard() Task { @MainActor in sending = false dismiss() diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index f870b0c..8788d66 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageBoardView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var composerDisplayed: Bool = false @State private var composerText: String = "" @@ -25,7 +25,7 @@ struct MessageBoardView: View { } .task { if !model.messageBoardLoaded { - let _ = await model.getMessageBoard() + let _ = try? await model.getMessageBoard() } } .overlay { @@ -98,5 +98,5 @@ struct MessageBoardView: View { #Preview { MessageBoardView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Chat/ChatView.swift b/Hotline/macOS/Chat/ChatView.swift index 0795856..e1ec73a 100644 --- a/Hotline/macOS/Chat/ChatView.swift +++ b/Hotline/macOS/Chat/ChatView.swift @@ -88,7 +88,7 @@ struct ChatMessageView: View { } struct ChatView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss @@ -98,12 +98,14 @@ struct ChatView: View { @State private var searchQuery: String = "" @State private var searchResults: [ChatMessage] = [] @State private var isSearching: Bool = false + + @State private var stableBannerImage: Image? @FocusState private var focusedField: FocusedField? @Namespace var bottomID - private var bindableModel: Bindable<Hotline> { + private var bindableModel: Bindable<HotlineState> { Bindable(model) } @@ -114,7 +116,36 @@ struct ChatView: View { var displayedMessages: [ChatMessage] { searchQuery.isEmpty ? model.chat : searchResults } - + +// private var blurredBannerImage: some View { +// self.stableBannerImage? +// .resizable() +// .scaledToFit() +// .frame(maxWidth: 468.0) +// .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) +// .offset(y: 1.5) +// .blur(radius: 4) +// .opacity(0.2) +// } + + private var bannerView: some View { + ZStack { + self.stableBannerImage? + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .offset(y: 1.5) + .blur(radius: 4) + .opacity(0.2) + self.stableBannerImage? + .resizable() + .scaledToFit() + .frame(maxWidth: 468.0) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + var body: some View { @Bindable var bindModel = model @@ -129,28 +160,10 @@ struct ChatView: View { ForEach(displayedMessages) { msg in if msg.type == .agreement { VStack(alignment: .center, spacing: 16) { - if let bannerImage = self.model.bannerImage { - HStack(spacing: 0) { - Spacer(minLength: 0) - ZStack { - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .offset(y: 1.5) - .blur(radius: 4) - .opacity(0.2) - - Image(nsImage: bannerImage) - .resizable() - .scaledToFit() - .frame(maxWidth: 468.0) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - - Spacer(minLength: 0) - } + HStack(spacing: 0) { + Spacer(minLength: 0) + self.bannerView + Spacer(minLength: 0) } ServerAgreementView(text: msg.text) @@ -218,7 +231,11 @@ struct ChatView: View { .multilineTextAlignment(.leading) .onSubmit { if !model.chatInput.isEmpty { - model.sendChat(model.chatInput, announce: NSEvent.modifierFlags.contains(.shift)) + let message = model.chatInput + let announce = NSEvent.modifierFlags.contains(.shift) + Task { + try? await model.sendChat(message, announce: announce) + } } model.chatInput = "" } @@ -252,6 +269,12 @@ struct ChatView: View { .onChange(of: searchQuery) { performSearch() } + .onChange(of: model.bannerImage) { oldValue, newValue in + stableBannerImage = newValue + } + .onAppear { + stableBannerImage = model.bannerImage + } // .toolbar { // ToolbarItem(placement: .primaryAction) { // Button { @@ -318,5 +341,5 @@ struct ChatView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Files/FileDetailsView.swift b/Hotline/macOS/Files/FileDetailsView.swift index 812f4e5..d001df2 100644 --- a/Hotline/macOS/Files/FileDetailsView.swift +++ b/Hotline/macOS/Files/FileDetailsView.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI struct FileDetailsView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.presentationMode) var presentationMode var fd: FileDetails @@ -73,8 +73,8 @@ struct FileDetailsView: View { if comment != fd.comment { editedComment = comment } - - model.client.sendSetFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) + + model.setFileInfo(fileName: fd.name, path: fd.path, fileNewName: editedFilename, comment: editedComment) presentationMode.wrappedValue.dismiss() // TODO: Update the file list if the filename was changed diff --git a/Hotline/macOS/Files/FileItemView.swift b/Hotline/macOS/Files/FileItemView.swift index 5da744f..31a8af7 100644 --- a/Hotline/macOS/Files/FileItemView.swift +++ b/Hotline/macOS/Files/FileItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct FileItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState var file: FileInfo let depth: Int diff --git a/Hotline/macOS/Files/FilePreviewImageView.swift b/Hotline/macOS/Files/FilePreviewImageView.swift index 9beeb80..c5899bd 100644 --- a/Hotline/macOS/Files/FilePreviewImageView.swift +++ b/Hotline/macOS/Files/FilePreviewImageView.swift @@ -12,7 +12,7 @@ struct FilePreviewImageView: View { @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil + @State var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -78,13 +78,16 @@ struct FilePreviewImageView: View { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } } label: { Label("Download Image...", systemImage: "arrow.down") } .help("Download Image") } - + ToolbarItem(placement: .primaryAction) { ShareLink(item: img, preview: SharePreview(info.name, image: img)) { Label("Share Image...", systemImage: "square.and.arrow.up") @@ -96,7 +99,7 @@ struct FilePreviewImageView: View { } .task { if let info = info { - preview = FilePreview(info: info) + preview = FilePreviewState(info: info) preview?.download() } } diff --git a/Hotline/macOS/Files/FilePreviewQuickLookView.swift b/Hotline/macOS/Files/FilePreviewQuickLookView.swift new file mode 100644 index 0000000..0323fdd --- /dev/null +++ b/Hotline/macOS/Files/FilePreviewQuickLookView.swift @@ -0,0 +1,131 @@ +// +// FilePreviewQuickLookView.swift +// Hotline +// +// QuickLook-based file preview window for all supported file types +// + +import SwiftUI +import UniformTypeIdentifiers + +struct FilePreviewQuickLookView: View { + enum FilePreviewFocus: Hashable { + case window + } + + @Environment(\.controlActiveState) private var controlActiveState + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) var dismiss + + @Binding var info: PreviewFileInfo? + @State var preview: FilePreviewState? = nil + @FocusState private var focusField: FilePreviewFocus? + + var body: some View { + Group { + if preview?.state != .loaded { + VStack(alignment: .center, spacing: 0) { + Spacer() + ProgressView(value: max(0.0, min(1.0, preview?.progress ?? 0.0))) + .focusable(false) + .progressViewStyle(.circular) + .controlSize(.extraLarge) + .frame(maxWidth: 300, alignment: .center) + .padding(.bottom, 48) + Spacer() + } + .background(Color(nsColor: .textBackgroundColor)) + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + else { + if let fileURL = preview?.fileURL { + QuickLookPreviewView(fileURL: fileURL) + .frame(minWidth: 400, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) + } + else { + VStack(alignment: .center, spacing: 0) { + Spacer() + + Image(systemName: "eye.trianglebadge.exclamationmark") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .frame(height: 48) + .padding(.bottom) + Group { + Text("This file type is not previewable") + .bold() + Text("Try downloading and opening this file in another application.") + .foregroundStyle(Color.secondary) + } + .font(.system(size: 14.0)) + .frame(maxWidth: 300) + .multilineTextAlignment(.center) + + Spacer() + Spacer() + } + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 150, maxHeight: .infinity) + .padding() + } + } + } + .focusable() + .focusEffectDisabled() + .background(Color(nsColor: .textBackgroundColor)) + .focused($focusField, equals: .window) + .navigationTitle(info?.name ?? "File Preview") + .background( + WindowConfigurator { window in + if let fileURL = preview?.fileURL { + window.representedURL = fileURL + window.standardWindowButton(.documentIconButton)?.isHidden = false + } + } + ) + .toolbar { + if let _ = preview?.fileURL { + if let info = info { + ToolbarItem(placement: .primaryAction) { + Button { + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } + } label: { + Label("Download File...", systemImage: "arrow.down") + } + .help("Download File") + } + } + } + } + .task { + if let info = info { + preview = FilePreviewState(info: info) + preview?.download() + } + } + .onAppear { + if info == nil { + Task { + dismiss() + } + return + } + + focusField = .window + } + .onDisappear { + preview?.cancel() + dismiss() + } + .onChange(of: preview?.state) { + if preview?.state == .failed { + dismiss() + } + } + .preferredColorScheme(.dark) + } +} diff --git a/Hotline/macOS/Files/FilePreviewTextView.swift b/Hotline/macOS/Files/FilePreviewTextView.swift index c286381..4e3a719 100644 --- a/Hotline/macOS/Files/FilePreviewTextView.swift +++ b/Hotline/macOS/Files/FilePreviewTextView.swift @@ -11,7 +11,7 @@ struct FilePreviewTextView: View { @Environment(\.dismiss) var dismiss @Binding var info: PreviewFileInfo? - @State var preview: FilePreview? = nil + @State var preview: FilePreviewState? = nil @FocusState private var focusField: FilePreviewFocus? var body: some View { @@ -89,7 +89,10 @@ struct FilePreviewTextView: View { if let info = info { ToolbarItem(placement: .primaryAction) { Button { - let _ = preview?.data?.saveAsFileToDownloads(filename: info.name) + if let fileURL = preview?.fileURL, + let data = try? Data(contentsOf: fileURL) { + let _ = data.saveAsFileToDownloads(filename: info.name) + } } label: { Label("Save Text File...", systemImage: "square.and.arrow.down") } @@ -100,7 +103,7 @@ struct FilePreviewTextView: View { } .task { if let info = info { - preview = FilePreview(info: info) + preview = FilePreviewState(info: info) preview?.download() } } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index de699ff..b499fe6 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -7,7 +7,7 @@ import AppKit struct FilesView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @State private var selection: FileInfo? @@ -15,6 +15,7 @@ struct FilesView: View { @State private var uploadFileSelectorDisplayed: Bool = false @State private var searchText: String = "" @State private var isSearching: Bool = false + @State private var dragOver: Bool = false private var isShowingSearchResults: Bool { switch model.fileSearchStatus { @@ -68,17 +69,18 @@ struct FilesView: View { private func openPreviewWindow(_ previewInfo: PreviewFileInfo) { switch previewInfo.previewType { case .image: - openWindow(id: "preview-image", value: previewInfo) + openWindow(id: "preview-quicklook", value: previewInfo) case .text: - openWindow(id: "preview-text", value: previewInfo) - default: + openWindow(id: "preview-quicklook", value: previewInfo) + case .unknown: + openWindow(id: "preview-quicklook", value: previewInfo) return } } @MainActor private func getFileInfo(_ file: FileInfo) { Task { - if let fileInfo = await model.getFileDetails(file.name, path: file.path) { + if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { Task { @MainActor in self.fileDetails = fileInfo } @@ -88,10 +90,10 @@ struct FilesView: View { @MainActor private func downloadFile(_ file: FileInfo) { if file.isFolder { - model.downloadFolder(file.name, path: file.path) + model.downloadFolderNew(file.name, path: file.path) } else { - model.downloadFile(file.name, path: file.path) + model.downloadFileNew(file.name, path: file.path) } } @@ -99,7 +101,31 @@ struct FilesView: View { model.uploadFile(url: fileURL, path: path) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: path) + let _ = try? await model.getFileList(path: path) + } + } + } + + @MainActor private func upload(file fileURL: URL, to path: [String]) { + var fileIsDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: fileURL.path(percentEncoded: false), isDirectory: &fileIsDirectory) else { + return + } + + if fileIsDirectory.boolValue { + self.model.uploadFolder(url: fileURL, path: path, complete: { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } + }) + } + else { + self.model.uploadFile(url: fileURL, path: path) { info in + Task { + // Refresh file listing to display newly uploaded file. + try? await model.getFileList(path: path) + } } } } @@ -121,15 +147,15 @@ struct FilesView: View { if file.path.count > 1 { parentPath = Array(file.path[0..<file.path.count-1]) } - - if await model.deleteFile(file.name, path: file.path) { - let _ = await model.getFileList(path: parentPath) + + if (try? await model.deleteFile(file.name, path: file.path)) == true { + let _ = try? await model.getFileList(path: parentPath) } } var body: some View { NavigationStack { - List(displayedFiles, id: \.self, selection: $selection) { file in + List(self.displayedFiles, id: \.self, selection: self.$selection) { file in if file.isFolder { FolderItemView(file: file, depth: 0).tag(file.id) } @@ -140,9 +166,36 @@ struct FilesView: View { .environment(\.defaultMinListRowHeight, 28) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) + .onDrop(of: [.fileURL], isTargeted: self.$dragOver) { items in + guard self.model.access?.contains(.canUploadFiles) == true, + let item = items.first, + let identifier = item.registeredTypeIdentifiers.first else { + return false + } + + item.loadItem(forTypeIdentifier: identifier, options: nil) { (urlData, error) in + DispatchQueue.main.async { + if let urlData = urlData as? Data, + let fileURL = URL(dataRepresentation: urlData, relativeTo: nil, isAbsolute: true) { + + // Access security-scoped resource for drag-and-drop + let didStartAccessing = fileURL.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + fileURL.stopAccessingSecurityScopedResource() + } + } + + self.upload(file: fileURL, to: []) + } + } + } + + return true + } .task { - if !model.filesLoaded { - let _ = await model.getFileList() + if !self.model.filesLoaded { + let _ = try? await self.model.getFileList() } } .contextMenu(forSelectionType: FileInfo.self) { items in @@ -246,7 +299,7 @@ struct FilesView: View { Label("Preview", systemImage: "eye") } .help("Preview") - .disabled(selection == nil || selection?.isPreviewable == false) + .disabled(selection == nil || selection?.isPreviewable != true) Button { if let selectedFile = selection { @@ -264,10 +317,7 @@ struct FilesView: View { Label("Upload", systemImage: "arrow.up") } .help("Upload") - .disabled( - model.access?.contains(.canUploadFiles) != true || - (model.fileSearchStatus.isActive && !(selection?.isFolder ?? false)) - ) + .disabled(model.access?.contains(.canUploadFiles) != true) Button { if let selectedFile = selection { @@ -284,17 +334,15 @@ struct FilesView: View { .sheet(item: $fileDetails ) { item in FileDetailsView(fd: item) } - .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data], allowsMultipleSelection: false, onCompletion: { results in + .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in switch results { case .success(let fileURLS): - guard fileURLS.count > 0 else { + guard fileURLS.count > 0, + let fileURL = fileURLS.first + else { return } - let fileURL = fileURLS.first! - - print(fileURL) - var uploadPath: [String] = [] if let selection = selection { @@ -308,7 +356,8 @@ struct FilesView: View { } print("UPLOAD PATH: \(uploadPath)") - uploadFile(file: fileURL, to: uploadPath) + self.upload(file: fileURL, to: uploadPath) +// uploadFile(file: fileURL, to: uploadPath) case .failure(let error): print(error) @@ -414,5 +463,5 @@ struct FilesView: View { #Preview { FilesView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 4a08974..2b1b695 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct FolderItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State var loading = false @State var dragOver = false @@ -20,7 +20,7 @@ struct FolderItemView: View { model.uploadFile(url: fileURL, path: filePath) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = await model.getFileList(path: filePath) + let _ = try? await model.getFileList(path: filePath) } } } @@ -103,7 +103,7 @@ struct FolderItemView: View { if file.expanded && file.fileSize > 0 { Task { loading = true - let _ = await model.getFileList(path: file.path) + let _ = try? await model.getFileList(path: file.path) loading = false } } diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index ee4d198..7819c2f 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -3,10 +3,27 @@ import SwiftUI struct HotlinePanelView: View { @Environment(\.openWindow) var openWindow @Environment(\.colorScheme) var colorScheme + @Environment(\.appState) private var appState + + private var activeServerState: ServerState? { + self.appState.activeServerState + } + + private var activeHotline: HotlineState? { + self.appState.activeHotline + } + + private var bannerImage: Image { + self.activeHotline?.bannerImage ?? Image("Default Banner") + } + + private var backgroundColor: Color { + Color(nsColor: self.activeHotline?.bannerColors?.backgroundColor ?? NSColor.controlBackgroundColor) + } var body: some View { VStack(spacing: 0) { - Image(nsImage: AppState.shared.activeServerState?.serverBanner ?? NSImage(named: "Default Banner")!) + self.bannerImage .interpolation(.high) .resizable() .scaledToFill() @@ -14,8 +31,7 @@ struct HotlinePanelView: View { .frame(minWidth: 468, maxWidth: 468, minHeight: 60, maxHeight: 60) .clipped() .background(.black) -// .clipShape(RoundedRectangle(cornerRadius: 6.0)) -// .padding([.top, .leading, .trailing], 4) + .animation(.default, value: self.bannerImage) HStack(spacing: 12) { Button { @@ -36,7 +52,7 @@ struct HotlinePanelView: View { .help("Hotline Servers") Button { - AppState.shared.activeServerState?.selection = .chat + self.activeServerState?.selection = .chat } label: { Image("Section Chat") @@ -45,11 +61,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Public Chat") Button { - AppState.shared.activeServerState?.selection = .board + self.activeServerState?.selection = .board } label: { Image("Section Board") @@ -58,11 +74,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Message Board") Button { - AppState.shared.activeServerState?.selection = .news + self.activeServerState?.selection = .news } label: { Image("Section News") @@ -71,11 +87,11 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil || (AppState.shared.activeHotline?.serverVersion ?? 0) < 151) + .disabled(self.activeServerState == nil || (self.activeHotline?.serverVersion ?? 0) < 151) .help("News") Button { - AppState.shared.activeServerState?.selection = .files + self.activeServerState?.selection = .files } label: { Image("Section Files") @@ -84,14 +100,14 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Files") Spacer() - if AppState.shared.activeHotline?.access?.contains(.canOpenUsers) == true { + if self.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - AppState.shared.activeServerState?.selection = .accounts + self.activeServerState?.selection = .accounts } label: { Image("Section Users") @@ -100,7 +116,7 @@ struct HotlinePanelView: View { } .buttonStyle(.plain) .frame(width: 20, height: 20) - .disabled(AppState.shared.activeServerState == nil) + .disabled(self.activeServerState == nil) .help("Accounts") } @@ -116,9 +132,9 @@ struct HotlinePanelView: View { .padding(.top, 12) .padding(.bottom, 12) .padding([.leading, .trailing], 12) - .background(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.backgroundColor) } ?? Color(nsColor: .controlBackgroundColor)) - .foregroundStyle(AppState.shared.activeServerState?.bannerColors.map { Color(nsColor: $0.primaryColor) } ?? Color.primary) -// .background(Color.red.opacity(0.5).blendMode(.multiply)) + .background(self.backgroundColor) + .foregroundStyle(.primary) + .animation(.default, value: self.backgroundColor) // GroupBox { // HStack(spacing: 0) { @@ -146,5 +162,5 @@ struct HotlinePanelView: View { #Preview { HotlinePanelView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 0a8b071..b0c8baa 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @State private var input: String = "" @@ -75,7 +75,11 @@ struct MessageView: View { .multilineTextAlignment(.leading) .onSubmit { if !self.input.isEmpty { - model.sendInstantMessage(self.input, userID: self.userID) + let message = self.input + let uid = self.userID + Task { + try? await model.sendInstantMessage(message, userID: uid) + } } self.input = "" } @@ -107,5 +111,5 @@ struct MessageView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/News/NewsEditorView.swift b/Hotline/macOS/News/NewsEditorView.swift index f69c846..cc3e7d7 100644 --- a/Hotline/macOS/News/NewsEditorView.swift +++ b/Hotline/macOS/News/NewsEditorView.swift @@ -9,7 +9,7 @@ struct NewsEditorView: View { @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState let editorTitle: String let isReply: Bool @@ -24,15 +24,16 @@ struct NewsEditorView: View { func sendArticle() async -> Bool { sending = true - - let success = await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) - if success { - await model.getNewsList(at: path) + + do { + try await model.postNewsArticle(title: title, body: text, at: path, parentID: parentID) + try? await model.getNewsList(at: path) + sending = false + return true + } catch { + sending = false + return false } - - sending = false - - return success } var body: some View { diff --git a/Hotline/macOS/News/NewsItemView.swift b/Hotline/macOS/News/NewsItemView.swift index fc20e61..29c0e7d 100644 --- a/Hotline/macOS/News/NewsItemView.swift +++ b/Hotline/macOS/News/NewsItemView.swift @@ -1,7 +1,7 @@ import SwiftUI struct NewsItemView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState var news: NewsInfo let depth: Int @@ -132,9 +132,9 @@ struct NewsItemView: View { guard news.expanded, news.type == .bundle || news.type == .category else { return } - + Task { - await model.getNewsList(at: news.path) + try? await model.getNewsList(at: news.path) } } @@ -148,5 +148,5 @@ struct NewsItemView: View { #Preview { NewsItemView(news: NewsInfo(hotlineNewsArticle: HotlineNewsArticle(id: 0, parentID: 0, flags: 0, title: "Title", username: "username", date: Date.now, flavors: [("", 1)], path: ["Guest"])), depth: 0) - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index 91bf2fe..dfc5ab2 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -3,7 +3,7 @@ import MarkdownUI import SplitView struct NewsView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.openWindow) private var openWindow @Environment(\.colorScheme) private var colorScheme @@ -64,7 +64,7 @@ struct NewsView: View { .task { if !model.newsLoaded { loading = true - await model.getNewsList() + try? await model.getNewsList() loading = false } } @@ -123,13 +123,13 @@ struct NewsView: View { loading = true if let selectionPath = selection?.path { Task { - await model.getNewsList(at: selectionPath) + try? await model.getNewsList(at: selectionPath) loading = false } } else { Task { - await model.getNewsList() + try? await model.getNewsList() loading = false } } @@ -179,7 +179,7 @@ struct NewsView: View { if let articleFlavor = article.articleFlavors?.first, let articleID = article.articleID { Task { - if let articleText = await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { + if let articleText = try? await self.model.getNewsArticle(id: articleID, at: article.path, flavor: articleFlavor) { self.articleText = articleText } } @@ -293,5 +293,5 @@ struct NewsView: View { #Preview { NewsView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index e18d630..df3e54b 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -69,7 +69,7 @@ struct ListItemView: View { } extension FocusedValues { - @Entry var activeHotlineModel: Hotline? + @Entry var activeHotlineModel: HotlineState? @Entry var activeServerState: ServerState? } @@ -80,7 +80,7 @@ struct ServerView: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.modelContext) private var modelContext - @State private var model: Hotline = Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient()) + @State private var model: HotlineState = HotlineState() @State private var state: ServerState = ServerState(selection: .chat) @State private var agreementShown: Bool = false @State private var connectAddress: String = "" @@ -119,6 +119,27 @@ struct ServerView: View { connectForm .navigationTitle("Connect to Server") } + else if case .failed(let error) = model.status { + VStack { + Image("Hotline") + .resizable() + .renderingMode(.template) + .scaledToFit() + .foregroundColor(Color(hex: 0xE10000)) + .frame(width: 18) + .opacity(controlActiveState == .inactive ? 0.5 : 1.0) + .padding(.trailing, 4) + + Text("Connection Failed") + .font(.headline) + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 300) + .padding() + .navigationTitle("Connection Failed") + } else if model.status != .loggedIn { HStack { Image("Hotline") @@ -129,7 +150,7 @@ struct ServerView: View { .frame(width: 18) .opacity(controlActiveState == .inactive ? 0.5 : 1.0) .padding(.trailing, 4) - + ProgressView(value: connectionStatusToProgress(status: model.status)) { Text(connectionStatusToLabel(status: model.status)) } @@ -142,12 +163,24 @@ struct ServerView: View { else { serverView .environment(model) - .onChange(of: Prefs.shared.userIconID) { sendPreferences() } - .onChange(of: Prefs.shared.username) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateMessages) { sendPreferences() } - .onChange(of: Prefs.shared.refusePrivateChat) { sendPreferences() } - .onChange(of: Prefs.shared.enableAutomaticMessage) { sendPreferences() } - .onChange(of: Prefs.shared.automaticMessage) { sendPreferences() } + .onChange(of: Prefs.shared.userIconID) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.username) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateMessages) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.refusePrivateChat) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.enableAutomaticMessage) { + Task { try? await model.sendUserPreferences() } + } + .onChange(of: Prefs.shared.automaticMessage) { + Task { try? await model.sendUserPreferences() } + } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { @@ -172,21 +205,23 @@ struct ServerView: View { } } .onDisappear { - model.disconnect() - } - .onChange(of: model.serverTitle) { oldTitle, newTitle in - state.serverName = newTitle - } - .onChange(of: model.bannerImage) { oldBanner, newBanner in - withAnimation { - state.serverBanner = newBanner - if let banner = newBanner { - state.bannerColors = ColorArt(image: banner, scaledSize: NSSize(width: 100, height: 100)) - } else { - state.bannerColors = nil - } + Task { + await model.disconnect() } } + .onChange(of: model.serverTitle) { + state.serverName = model.serverTitle + } +// .onChange(of: model.bannerImage) { +// state.serverBanner = model.bannerImage +// } +// .onChange(of: model.bannerColors) { +// guard let backgroundColor = model.bannerColors?.backgroundColor else { +// state.bannerBackgroundColor = nil +// return +// } +// state.bannerBackgroundColor = Color(nsColor: backgroundColor) +// } .alert(model.errorMessage ?? "Server Error", isPresented: $model.errorDisplayed) { Button("OK") {} } @@ -310,16 +345,18 @@ struct ServerView: View { if !name.isEmpty { connectNameSheetPresented = false connectName = "" - Task.detached { - let (host, port) = Server.parseServerAddressAndPort(connectAddress) - let login: String? = connectLogin.isEmpty ? nil : connectLogin - let password: String? = connectPassword.isEmpty ? nil : connectPassword - - if !host.isEmpty { - let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) - Bookmark.add(newBookmark, context: modelContext) - } +// Task.detached { + + let (host, port) = Server.parseServerAddressAndPort(connectAddress) + let login: String? = connectLogin.isEmpty ? nil : connectLogin + let password: String? = connectPassword.isEmpty ? nil : connectPassword + + if !host.isEmpty { + let newBookmark = Bookmark(type: .server, name: name, address: host, port: port, login: login, password: password) + Bookmark.add(newBookmark, context: modelContext) } + +// } } } } @@ -390,7 +427,7 @@ struct ServerView: View { // Section("\(model.users.count) Online") { ForEach(model.users) { user in HStack(spacing: 5) { - if let iconImage = Hotline.getClassicIcon(Int(user.iconID)) { + if let iconImage = HotlineState.getClassicIcon(Int(user.iconID)) { Image(nsImage: iconImage) .frame(width: 16, height: 16) .padding(.leading, 2) @@ -476,35 +513,37 @@ struct ServerView: View { guard !server.address.isEmpty else { return } - model.login(server: server, username: Prefs.shared.username, iconID: Prefs.shared.userIconID) { success in - if !success { - print("FAILED LOGIN??") - model.disconnect() - } - else { - sendPreferences() - model.getUserList() - model.downloadBanner() + + Task { @MainActor in + do { + // login() handles everything: connect, getUserList, sendPreferences, downloadBanner + try await model.login( + server: server, + username: Prefs.shared.username, + iconID: Prefs.shared.userIconID + ) + } catch { + print("ServerView: Login failed: \(error)") } } } - private func connectionStatusToProgress(status: HotlineClientStatus) -> Double { + private func connectionStatusToProgress(status: HotlineConnectionStatus) -> Double { switch status { case .disconnected: return 0.0 case .connecting: return 0.4 case .connected: - return 0.75 - case .loggingIn: return 0.9 case .loggedIn: return 1.0 + case .failed: + return 0.0 } } - - private func connectionStatusToLabel(status: HotlineClientStatus) -> String { + + private func connectionStatusToLabel(status: HotlineConnectionStatus) -> String { let n = server.name ?? server.address switch status { case .disconnected: @@ -512,42 +551,21 @@ struct ServerView: View { case .connecting: return "Connecting to \(n)..." case .connected: - return "Connected to \(n)" - case .loggingIn: return "Logging in to \(n)..." case .loggedIn: return "Logged in to \(n)" + case .failed(let error): + return "Failed: \(error)" } } - @MainActor func sendPreferences() { - if self.model.status == .loggedIn { - var options: HotlineUserOptions = HotlineUserOptions() - - if Prefs.shared.refusePrivateMessages { - options.update(with: .refusePrivateMessages) - } - - if Prefs.shared.refusePrivateChat { - options.update(with: .refusePrivateChat) - } - - if Prefs.shared.enableAutomaticMessage { - options.update(with: .automaticResponse) - } - - print("Updating preferences with server") - - self.model.sendUserInfo(username: Prefs.shared.username, iconID: Prefs.shared.userIconID, options: options, autoresponse: Prefs.shared.automaticMessage) - } - } } struct TransferItemView: View { let transfer: TransferInfo @Environment(\.controlActiveState) private var controlActiveState - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @State private var hovered: Bool = false @State private var buttonHovered: Bool = false @@ -559,8 +577,8 @@ struct TransferItemView: View { return "File transfer failed" } else if self.transfer.progress > 0.0 { - if self.transfer.timeRemaining > 0.0 { - return "\(round(self.transfer.progress * 100.0))% – \(self.transfer.timeRemaining) seconds left" + if let estimate = self.transfer.timeRemaining, estimate > 0.0 { + return "\(round(self.transfer.progress * 100.0))% – \(estimate) seconds left" } else { return "\(round(self.transfer.progress * 100.0))% complete" @@ -597,7 +615,7 @@ struct TransferItemView: View { if self.hovered { Button { - model.deleteTransfer(id: transfer.id) + AppState.shared.cancelTransfer(id: transfer.id) } label: { Image(systemName: self.buttonHovered ? "xmark.circle.fill" : "xmark.circle") .resizable() @@ -657,4 +675,3 @@ struct TransferItemView: View { .help(formattedProgressHelp()) } } - diff --git a/Hotline/macOS/Settings/IconSettingsView.swift b/Hotline/macOS/Settings/IconSettingsView.swift index 98cbb09..2bb92c4 100644 --- a/Hotline/macOS/Settings/IconSettingsView.swift +++ b/Hotline/macOS/Settings/IconSettingsView.swift @@ -18,7 +18,7 @@ struct IconSettingsView: View { GridItem(.fixed(4+32+4)), GridItem(.fixed(4+32+4)) ], spacing: 0) { - ForEach(Hotline.classicIconSet, id: \.self) { iconID in + ForEach(HotlineState.classicIconSet, id: \.self) { iconID in HStack { Image("Classic/\(iconID)") .resizable() diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift new file mode 100644 index 0000000..9932c0a --- /dev/null +++ b/Hotline/macOS/TransfersView.swift @@ -0,0 +1,169 @@ +import SwiftUI + +struct TransfersView: View { + @Environment(\.appState) private var appState + + var body: some View { + VStack(spacing: 0) { + if appState.transfers.isEmpty { + emptyState + } else { + transfersList + } + } + .frame(minWidth: 500, minHeight: 200) + .navigationTitle("Transfers") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + appState.cancelAllTransfers() + } label: { + Label("Cancel All", systemImage: "xmark.circle") + } + .disabled(appState.transfers.isEmpty) + } + } + } + + // MARK: - Empty State + + private var emptyState: some View { + ContentUnavailableView { + Label("No Transfers", systemImage: "arrow.up.arrow.down") + } description: { + Text("Your Hotline file transfers will appear here") + } + } + + // MARK: - Transfers List + + private var transfersList: some View { + List { + ForEach(appState.transfers) { transfer in + TransferRow(transfer: transfer) + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } +} + +// MARK: - Transfer Row + +struct TransferRow: View { + @Bindable var transfer: TransferInfo + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // File name and server + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(transfer.title) + .font(.system(.body, design: .default, weight: .medium)) + + if let serverName = transfer.serverName { + Text(serverName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // Cancel button + Button { + AppState.shared.cancelTransfer(id: transfer.id) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Cancel download") + } + + // Progress bar and status + VStack(alignment: .leading, spacing: 4) { + if transfer.failed { + Label("Failed", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + } else if transfer.completed { + Label("Complete", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.green) + } else { + // Progress bar + ProgressView(value: transfer.progress, total: 1.0) + .progressViewStyle(.linear) + + // Progress info + HStack(spacing: 8) { + // Progress percentage + Text("\(Int(transfer.progress * 100))%") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + + // File size + Text(formatSize(transfer.size)) + .font(.caption) + .foregroundStyle(.secondary) + + // Speed + if let speed = transfer.speed { + Text(formatSpeed(speed)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + // Time remaining + if let timeRemaining = transfer.timeRemaining { + Text(formatTimeRemaining(timeRemaining)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + } + } + .padding(.vertical, 4) + } + + // MARK: - Formatting + + private func formatSize(_ bytes: UInt) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func formatSpeed(_ bytesPerSecond: Double) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + return "\(formatter.string(fromByteCount: Int64(bytesPerSecond)))/s" + } + + private func formatTimeRemaining(_ seconds: TimeInterval) -> String { + if seconds < 60 { + return "\(Int(seconds))s" + } else if seconds < 3600 { + let minutes = Int(seconds / 60) + let secs = Int(seconds.truncatingRemainder(dividingBy: 60)) + return "\(minutes)m \(secs)s" + } else { + let hours = Int(seconds / 3600) + let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) + return "\(hours)h \(minutes)m" + } + } +} + +// MARK: - Preview + +#Preview { + TransfersView() + .environment(AppState.shared) +} |