aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS
diff options
context:
space:
mode:
Diffstat (limited to 'Hotline/macOS')
-rw-r--r--Hotline/macOS/Accounts/AccountManagerView.swift44
-rw-r--r--Hotline/macOS/Board/MessageBoardEditorView.swift14
-rw-r--r--Hotline/macOS/Board/MessageBoardView.swift6
-rw-r--r--Hotline/macOS/Chat/ChatView.swift77
-rw-r--r--Hotline/macOS/Files/FileDetailsView.swift6
-rw-r--r--Hotline/macOS/Files/FileItemView.swift2
-rw-r--r--Hotline/macOS/Files/FilePreviewImageView.swift11
-rw-r--r--Hotline/macOS/Files/FilePreviewQuickLookView.swift131
-rw-r--r--Hotline/macOS/Files/FilePreviewTextView.swift9
-rw-r--r--Hotline/macOS/Files/FilesView.swift103
-rw-r--r--Hotline/macOS/Files/FolderItemView.swift6
-rw-r--r--Hotline/macOS/HotlinePanelView.swift52
-rw-r--r--Hotline/macOS/MessageView.swift10
-rw-r--r--Hotline/macOS/News/NewsEditorView.swift19
-rw-r--r--Hotline/macOS/News/NewsItemView.swift8
-rw-r--r--Hotline/macOS/News/NewsView.swift12
-rw-r--r--Hotline/macOS/ServerView.swift165
-rw-r--r--Hotline/macOS/Settings/IconSettingsView.swift2
-rw-r--r--Hotline/macOS/TransfersView.swift169
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)
+}