From b58129025413367d8237b3147a9fa96bd4e6924f Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Tue, 11 Nov 2025 17:46:31 -0800 Subject: Fix up File Info sheet. --- Hotline/macOS/Files/FileDetailsSheet.swift | 141 +++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 Hotline/macOS/Files/FileDetailsSheet.swift (limited to 'Hotline/macOS/Files/FileDetailsSheet.swift') diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift new file mode 100644 index 0000000..d798fd4 --- /dev/null +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -0,0 +1,141 @@ +import Foundation +import SwiftUI + +struct FileDetailsSheet: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.presentationMode) var presentationMode + + var fd: FileDetails + + @State private var comment: String = "" + @State private var filename: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center, spacing: 16){ + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + TextField("", text: $filename) + .disabled(!self.canRename()) + } + + let rows: [(String, String)] = [ + ("Type", fd.type), + ("Creator", fd.creator), + ("Size", self.formattedSize(byteCount: fd.size)), + ("Created", Self.dateFormatter.string(from: fd.created)), + ("Modified", Self.dateFormatter.string(from: fd.modified)) + ] + + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { + ForEach(rows, id: \.0) { label, value in + GridRow { + Text(label) + .font(.body.bold()) + .gridColumnAlignment(.trailing) // right-align label column + Text(value) + .font(.body) + .gridColumnAlignment(.leading) // left-align value column + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 32 + 16) + + TextField(text: self.$comment, prompt: Text("Comments"), axis: .vertical) { + EmptyView() + } + .font(.body) + .lineLimit(10, reservesSpace: true) + .padding(.leading, 32 + 16) + .disabled(!self.canSetComment()) + } + .padding(.vertical, 24) + .padding(.horizontal, 24) + .frame(width: 400) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .primaryAction) { + Button{ + var editedFilename: String? + if filename != fd.name { + editedFilename = filename + } + + var editedComment: String? + if comment != fd.comment { + editedComment = comment + } + + 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 + } label: { + Text("Save") + }.disabled(!isEdited()) + } + } + .onAppear { + self.filename = fd.name + self.comment = fd.comment + } + } + + + static var dateFormatter: DateFormatter = { + var dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + + // Original format: Fri, Aug 20, 2021, 5:14:07 PM + return dateFormatter + }() + + static var byteCountSizeFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + return numberFormatter + }() + + static let byteFormatter = ByteCountFormatter() + + private func formattedFileSize(_ fileSize: UInt) -> String { + FileItemView.byteFormatter.allowedUnits = [.useAll] + FileItemView.byteFormatter.countStyle = .file + return FileItemView.byteFormatter.string(fromByteCount: Int64(fileSize)) + } + + // Format byte count Int into string like: 23.4M (24,601,664 bytes) + private func formattedSize(byteCount: Int) -> String { + let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" + return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" + } + + private func isEdited() -> Bool { + return self.filename != fd.name || self.comment != fd.comment + } + + private func canRename() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canRenameFolders) == true + } + return model.access?.contains(.canRenameFiles) == true + } + + private func canSetComment() -> Bool { + if self.fd.type == "fldr" { + return model.access?.contains(.canSetFolderComment) == true + } + return model.access?.contains(.canSetFileComment) == true + } +} + +//#Preview { +// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +//} -- cgit From 5eff79cc4891076d85223dcfd96c7cb8894c80f2 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Wed, 12 Nov 2025 18:55:25 -0800 Subject: Get Accounts out of the sidebar, redesign Account management. --- Hotline.xcodeproj/project.pbxproj | 8 +- Hotline/Hotline/HotlineClient.swift | 42 +- Hotline/Hotline/HotlineExtensions.swift | 44 +- Hotline/Hotline/HotlineProtocol.swift | 6 +- Hotline/Hotline/HotlineTrackerClient.swift | 21 +- Hotline/MacApp.swift | 6 +- Hotline/Models/FileDetails.swift | 4 +- Hotline/State/AppUpdate.swift | 20 +- Hotline/State/ServerState.swift | 7 +- Hotline/macOS/Accounts/AccountManagerView.swift | 686 ++++++++++++------------ Hotline/macOS/Files/FileDetailsSheet.swift | 10 +- Hotline/macOS/Files/FilesView.swift | 26 +- Hotline/macOS/Files/NewFolderPopover.swift | 51 ++ Hotline/macOS/Files/NewFolderSheet.swift | 32 -- Hotline/macOS/HotlinePanelView.swift | 5 +- Hotline/macOS/ServerView.swift | 59 +- Hotline/macOS/TransfersView.swift | 6 +- 17 files changed, 548 insertions(+), 485 deletions(-) create mode 100644 Hotline/macOS/Files/NewFolderPopover.swift delete mode 100644 Hotline/macOS/Files/NewFolderSheet.swift (limited to 'Hotline/macOS/Files/FileDetailsSheet.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 1f9bdef..7032420 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -22,7 +22,7 @@ DA32CD4B2B29318E0053B98B /* FileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4A2B29318E0053B98B /* FileInfo.swift */; }; DA32CD4D2B2931B50053B98B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4C2B2931B50053B98B /* ChatMessage.swift */; }; DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */; }; - DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */; }; + DA32F1CC2EC4175F00B243BC /* NewFolderPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */; }; DA3429AE2EB9C0280010784E /* HotlineFileUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */; }; DA3429B02EBA70790010784E /* HotlineFolderUploadClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */; }; DA3429B32EBA7ADF0010784E /* HotlineFilePreviewClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */; }; @@ -137,7 +137,7 @@ DA32CD4A2B29318E0053B98B /* FileInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileInfo.swift; sourceTree = ""; }; DA32CD4C2B2931B50053B98B /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; DA32CD4E2B2931CC0053B98B /* NewsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsInfo.swift; sourceTree = ""; }; - DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderSheet.swift; sourceTree = ""; }; + DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewFolderPopover.swift; sourceTree = ""; }; DA3429AD2EB9C0220010784E /* HotlineFileUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFileUploadClient.swift; sourceTree = ""; }; DA3429AF2EBA70790010784E /* HotlineFolderUploadClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFolderUploadClient.swift; sourceTree = ""; }; DA3429B22EBA7ADE0010784E /* HotlineFilePreviewClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotlineFilePreviewClient.swift; sourceTree = ""; }; @@ -341,7 +341,7 @@ DAE735002B2E71F2000C56F6 /* FilesView.swift */, DA5268A42EB0743000DCB941 /* FileItemView.swift */, DA5268A22EB0741B00DCB941 /* FolderItemView.swift */, - DA32F1CB2EC4175F00B243BC /* NewFolderSheet.swift */, + DA32F1CB2EC4175F00B243BC /* NewFolderPopover.swift */, 11A726092BE06759000C1DA7 /* FileDetailsSheet.swift */, DAAEE66E2B47625600A5BA07 /* FilePreviewImageView.swift */, DAB4D87D2B4C8BCA0048A05C /* FilePreviewTextView.swift */, @@ -681,7 +681,7 @@ DA32CD4F2B2931CC0053B98B /* NewsInfo.swift in Sources */, DA9CAFBB2B126D5700CDA197 /* MacApp.swift in Sources */, DAAEE66B2B3FBC2100A5BA07 /* TransferInfo.swift in Sources */, - DA32F1CC2EC4175F00B243BC /* NewFolderSheet.swift in Sources */, + DA32F1CC2EC4175F00B243BC /* NewFolderPopover.swift in Sources */, DA0D698F2B1E841600C71DF5 /* MessageBoardView.swift in Sources */, DAE735072B3251B3000C56F6 /* SoundEffects.swift in Sources */, DA55AC752BE4888300034857 /* InstantMessage.swift in Sources */, diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1faaf55..ee99ed7 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -142,10 +142,6 @@ public actor HotlineClient { // Transaction tracking for request/reply pattern private var pendingTransactions: [UInt32: CheckedContinuation] = [:] - private enum TransactionWaitError: Error { - case timeout - } - // Receive loop task private var receiveTask: Task? @@ -427,10 +423,10 @@ public actor HotlineClient { try await self.socket.send(transaction, endian: .big) do { - return try await withTimeout(seconds: timeout) { + return try await Task.withTimeout(seconds: timeout) { try await self.awaitReply(for: transactionID) } - } catch is TransactionWaitError { + } catch is TaskTimeoutError { throw HotlineClientError.timeout } catch let error as HotlineClientError { throw error @@ -467,32 +463,6 @@ public actor HotlineClient { } } - private func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { - if seconds <= 0 { - throw TransactionWaitError.timeout - } - - return try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw TransactionWaitError.timeout - } - - do { - let value = try await group.next()! - group.cancelAll() - return value - } catch { - group.cancelAll() - throw error - } - } - } - // MARK: - Keep-Alive private func startKeepAlive() { @@ -841,7 +811,7 @@ public actor HotlineClient { accounts.append(data.getAcccount()) } - accounts.sort { $0.login < $1.login } + accounts.sort { $0.name < $1.name } return accounts } @@ -893,7 +863,11 @@ public actor HotlineClient { // - other: Set new password if password == nil { transaction.setFieldUInt8(type: .userPassword, val: 0) - } else if password != "" { + } + else if password == "" { + // Don't add password to transaction (password will be removed) + } + else { transaction.setFieldEncodedString(type: .userPassword, val: password!) } diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index 6782a41..bcee812 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -7,6 +7,8 @@ enum LineEnding { case cr // Classic Mac-style (\r) } +// MARK: - + extension URL { func urlForResourceFork() -> URL { self.appendingPathComponent("..namedfork/rsrc") @@ -31,6 +33,40 @@ extension URL { #endif } +// MARK: - + +public struct TaskTimeoutError: Error {} + +extension Task where Success == Never, Failure == Never { + public static func withTimeout(seconds: TimeInterval, operation: @escaping @Sendable () async throws -> T) async throws -> T { + if seconds <= 0 { + throw TaskTimeoutError() + } + + return try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TaskTimeoutError() + } + + do { + let value = try await group.next()! + group.cancelAll() + return value + } catch { + group.cancelAll() + throw error + } + } + } +} + +// MARK: - + extension String { func convertingLineEndings(to targetEnding: LineEnding) -> String { let lf = "\n" @@ -38,16 +74,16 @@ extension String { let cr = "\r" // Normalize all line endings to LF (\n) - let normalizedString = self.replacingOccurrences(of: cr, with: lf).replacingOccurrences(of: crlf, with: lf) + let normalizedString = self.replacing(cr, with: lf).replacing(crlf, with: lf) // Replace normalized LF (\n) line endings with the target line ending switch targetEnding { case .lf: return normalizedString case .crlf: - return normalizedString.replacingOccurrences(of: lf, with: crlf) + return normalizedString.replacing(lf, with: crlf) case .cr: - return normalizedString.replacingOccurrences(of: lf, with: cr) + return normalizedString.replacing(lf, with: cr) } } @@ -67,6 +103,8 @@ extension String { } } +// MARK: - + extension FileManager { static var extensionToHFSCreator: [String: UInt32] = [ // Documents diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 5fd06dc..4857d87 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -233,18 +233,20 @@ public struct HotlineAccount: Identifiable { public let id: UUID = UUID() var name: String = "" var login: String = "" - var password: String? = nil - var persisted: Bool = true + var password: String = "" + var persisted: Bool = false var access: HotlineUserAccessOptions = HotlineUserAccessOptions() var fields: [HotlineTransactionField] = [] init(from data: [UInt8]) { self.decodeFields(from: data) + self.persisted = true } init(_ name: String, _ login: String, _ access: HotlineUserAccessOptions) { self.name = name self.login = login + self.password = HotlineAccount.randomPassword() self.access = access self.persisted = false } diff --git a/Hotline/Hotline/HotlineTrackerClient.swift b/Hotline/Hotline/HotlineTrackerClient.swift index 6a7a5b3..8580cd7 100644 --- a/Hotline/Hotline/HotlineTrackerClient.swift +++ b/Hotline/Hotline/HotlineTrackerClient.swift @@ -57,7 +57,7 @@ class HotlineTrackerClient { private func fetchServersInternal(address: String, port: Int, continuation: AsyncThrowingStream.Continuation) async { do { - try await withTimeout(seconds: 30) { + try await Task.withTimeout(seconds: 30) { try await self.doFetch(address: address, port: port, continuation: continuation) } } catch { @@ -160,23 +160,4 @@ class HotlineTrackerClient { print("HotlineTrackerClient: Completed - parsed \(totalEntriesParsed)/\(totalExpectedEntries) entries, yielded \(totalYielded) servers") continuation.finish() } - - private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { - try await operation() - } - - group.addTask { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - throw NSError(domain: "HotlineTracker", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "Tracker request timed out after \(seconds) seconds" - ]) - } - - let result = try await group.next()! - group.cancelAll() - return result - } - } } diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 9ac54c1..5a84da8 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -277,11 +277,11 @@ struct Application: App { if activeHotline?.access?.contains(.canOpenUsers) == true { Divider() - Button("Accounts") { - activeServerState?.selection = .accounts + Button("Manage Server...") { + activeServerState?.accountsShown = true } .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) - .keyboardShortcut(.init("5"), modifiers: .command) +// .keyboardShortcut(.init("5"), modifiers: .command) } } } diff --git a/Hotline/Models/FileDetails.swift b/Hotline/Models/FileDetails.swift index 6164151..71ddb6d 100644 --- a/Hotline/Models/FileDetails.swift +++ b/Hotline/Models/FileDetails.swift @@ -1,7 +1,7 @@ import UniformTypeIdentifiers -public struct FileDetails:Identifiable { - public let id = UUID() +public struct FileDetails: Identifiable { + public let id: UUID = UUID() var name: String var path: [String] var size: Int diff --git a/Hotline/State/AppUpdate.swift b/Hotline/State/AppUpdate.swift index 558a296..83006a2 100644 --- a/Hotline/State/AppUpdate.swift +++ b/Hotline/State/AppUpdate.swift @@ -144,12 +144,13 @@ final class AppUpdate { self.releaseNotesCombined = nil self.isDownloading = false if trigger == .manual { - self.message = AppUpdateMessage( - title: "Hotline is up to date", - detail: "You're running the latest and greatest.", - kind: .success - ) - self.showWindow = true + self.showUpToDateAlert() +// self.message = AppUpdateMessage( +// title: "Hotline is up to date", +// detail: "You're running the latest and greatest.", +// kind: .success +// ) +// self.showWindow = true } else { self.message = nil self.showWindow = false @@ -175,6 +176,13 @@ final class AppUpdate { } } + private func showUpToDateAlert() { + let alert = NSAlert() + alert.messageText = "No Update Available" + alert.informativeText = "You are already using the latest version of Hotline!" + alert.runModal() + } + private func fetchNewerReleases() async throws -> [UpdateReleaseInfo] { let (data, _) = try await URLSession.shared.data(from: releasesURL) guard let jsonArray = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 5913bf5..8f8d3bf 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -5,6 +5,7 @@ class ServerState: Equatable { var id: UUID = UUID() var selection: ServerNavigationType var serverName: String? = nil + var accountsShown: Bool = false // var serverBanner: NSImage? = nil // var bannerBackgroundColor: Color? = nil @@ -28,8 +29,8 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { return "Board" case .files: return "Files" - case .accounts: - return "Accounts" +// case .accounts: +// return "Accounts" case .user(let userID): return String(userID) } @@ -39,6 +40,6 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case news case board case files - case accounts +// case accounts case user(userID: UInt16) } diff --git a/Hotline/macOS/Accounts/AccountManagerView.swift b/Hotline/macOS/Accounts/AccountManagerView.swift index c45ba18..bbb909b 100644 --- a/Hotline/macOS/Accounts/AccountManagerView.swift +++ b/Hotline/macOS/Accounts/AccountManagerView.swift @@ -1,402 +1,418 @@ import SwiftUI +fileprivate let DEFAULT_ACCOUNT_NAME = "Untitled Account" +fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" + struct AccountManagerView: View { @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss @State private var accounts: [HotlineAccount] = [] @State private var selection: HotlineAccount? @State private var loading: Bool = true - @State private var pendingName: String = "" - @State private var pendingLogin: String = "" - @State private var pendingPassword: String = "" - @State private var pendingAccess = HotlineUserAccessOptions.defaultAccess + @State private var creatorShown: Bool = false + @State private var deleteConfirm: Bool = false + @State private var accountToEdit: HotlineAccount? = nil + @State private var accountToDelete: HotlineAccount? = nil - @State private var toDelete: HotlineAccount? + private func newAccount() { + self.creatorShown = true + } - let placeholderPassword = "xxxxxxxxxxxxxxxxxx" + private func editAccount(_ account: HotlineAccount) { + // Always get the latest version from the array to avoid stale data + if let currentAccount = self.accounts.first(where: { $0.id == account.id }) { + self.accountToEdit = currentAccount + } + } + + private func deleteAccount(_ account: HotlineAccount) { + self.accountToDelete = account + self.deleteConfirm = true + } var body: some View { - HStack(spacing: 0) { - ZStack { - accountList - if loading { - ProgressView() - } - } - if selection != nil { - accountDetails - } - else { - ZStack(alignment: .center) { - Text("No Account Selected") - .font(.title) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding() + VStack(spacing: 8) { + HStack(alignment: .firstTextBaseline) { + Text("Accounts") + .font(.headline) + + Spacer() + + HStack { + Button { + self.newAccount() + } label: { + Image(systemName: "plus") + .padding(4) + } + .buttonBorderShape(.circle) + .help("New Account") + + Button { + if let account = self.selection { + self.editAccount(account) + } + } label: { + Image(systemName: "pencil") + .padding(4) + } + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Edit Account") + + Button { + if let account = self.selection { + self.deleteAccount(account) + } + } label: { + Image(systemName: "trash") + .padding(4) + } + .tint(.hotlineRed) + .buttonBorderShape(.circle) + .disabled(self.selection == nil) + .help("Delete Account") } - .frame(maxWidth: .infinity) } + .padding(.horizontal, 16) + .padding(.top, 24) + + self.accountList +// .overlay { +// if self.loading { +// ProgressView() +// .progressViewStyle(.linear) +// .controlSize(.extraLarge) +// .frame(width: 100) +// } +// } } .environment(\.defaultMinListRowHeight, 34) .listStyle(.inset) .alternatingRowBackgrounds(.enabled) .task { - if loading { - accounts = (try? await model.getAccounts()) ?? [] - loading = false + if self.loading { + do { + self.accounts = try await self.model.getAccounts() + } + catch { + self.dismiss() + } + + self.loading = false } } .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - let newAccount = HotlineAccount("unnamed", "unnamed", HotlineUserAccessOptions.defaultAccess) - - pendingPassword = HotlineAccount.randomPassword() - accounts.append(newAccount) - selection = newAccount - } label: { - Label("New Account", systemImage: "plus") + if self.loading { + ToolbarItem { + ProgressView() + .controlSize(.small) } - .help("Create a new account") - .disabled(model.access?.contains(.canCreateUsers) != true) } - ToolbarItem(placement: .destructiveAction) { + ToolbarItem(placement: .confirmationAction) { Button { - toDelete = selection + self.dismiss() } label: { - Label("Delete Account", systemImage: "trash") + Text("OK") } - .help("Delete account") - .disabled(selection == nil || model.access?.contains(.canDeleteUsers) != true) } } } - var accountDetails: some View { - VStack(alignment: .center, spacing: 0) { - ScrollView(.vertical) { - Form { - Section { - TextField(text: $pendingName) { - Text("Name") - } - TextField("Login", text: $pendingLogin) - .disabled(selection?.persisted == true) - if selection?.persisted == true { - SecureField("Password", text: $pendingPassword) - } else { - TextField("Password", text: $pendingPassword) - } - } - .textFieldStyle(.roundedBorder) - .controlSize(.large) - - Section("File System Maintenance") { - Toggle("Can Download Files", isOn: $pendingAccess.bind(.canDownloadFiles)) - .disabled(model.access?.contains(.canDownloadFiles) == false) - Toggle("Can Download Folders", isOn: $pendingAccess.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) - Toggle("Can Upload Files", isOn: $pendingAccess.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) - Toggle("Can Upload Folders", isOn: $pendingAccess.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) - Toggle("Can Upload Anywhere", isOn: $pendingAccess.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) - Toggle("Can Delete Files", isOn: $pendingAccess.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) - Toggle("Can Rename Files", isOn: $pendingAccess.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) - Toggle("Can Move Files", isOn: $pendingAccess.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) - Toggle("Can Comment Files", isOn: $pendingAccess.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) - Toggle("Can Create Folders", isOn: $pendingAccess.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) - Toggle("Can Delete Folders", isOn: $pendingAccess.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) - Toggle("Can Rename Folders", isOn: $pendingAccess.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) - Toggle("Can Move Folders", isOn: $pendingAccess.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) - Toggle("Can Comment Folders", isOn: $pendingAccess.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) - Toggle("Can View Drop Boxes", isOn: $pendingAccess.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) - Toggle("Can Make Aliases", isOn: $pendingAccess.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) - } - - Section("User Maintenance") { - Toggle("Can Create Accounts", isOn: $pendingAccess.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) - Toggle("Can Delete Accounts", isOn: $pendingAccess.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) - Toggle("Can Read Accounts", isOn: $pendingAccess.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) - Toggle("Can Modify Accounts", isOn: $pendingAccess.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) - Toggle("Can Get User Info", isOn: $pendingAccess.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) - - Toggle("Can Disconnect Users", isOn: $pendingAccess.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) - Toggle("Cannot be Disconnected", isOn: $pendingAccess.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) - } - - Section("Messaging") { - Toggle("Can Send Messages", isOn: $pendingAccess.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) - Toggle("Can Broadcast", isOn: $pendingAccess.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) - } - - Section("News") { - Toggle("Can Read Articles", isOn: $pendingAccess.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) - Toggle("Can Post Articles", isOn: $pendingAccess.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) - Toggle("Can Delete Articles", isOn: $pendingAccess.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) - Toggle("Can Create Categories", isOn: $pendingAccess.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) - Toggle("Can Delete Categories", isOn: $pendingAccess.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) - Toggle("Can Create News Bundles", isOn: $pendingAccess.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) - Toggle("Can Delete News Bundles", isOn: $pendingAccess.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) - } - - Section("Chat") { - Toggle("Can Initiate Private Chat", isOn: $pendingAccess.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) - Toggle("Can Read Chat", isOn: $pendingAccess.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) - Toggle("Can Send Chat", isOn: $pendingAccess.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) - } - - Section("Miscellaneous") { - Toggle("Can Use Any Name", isOn: $pendingAccess.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) - Toggle("Don't Show Agreement", isOn: $pendingAccess.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) - } - } - .disabled(model.access?.contains(.canModifyUsers) == false) - .formStyle(.grouped) - .onChange(of: selection) { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } - } - } - .onAppear() { - if let selection { - pendingName = selection.name - pendingLogin = selection.login - pendingAccess = selection.access - - if selection.persisted { - if selection.password == nil { - pendingPassword = "" - } else { - pendingPassword = placeholderPassword - } - } else { - pendingPassword = HotlineAccount.randomPassword() - } - } + private var accountList: some View { + List(self.accounts, id: \.self, selection: self.$selection) { account in + HStack(spacing: 5) { + Image(account.access.contains(.canDisconnectUsers) ? "User Admin" : "User") + .frame(width: 16, height: 16) + .opacity((account.access.rawValue == 0) ? 0.5 : 1.0) + Text(account.name) + .foregroundStyle(account.access.contains(.canDisconnectUsers) ? Color.hotlineRed : ((account.access.rawValue == 0) ? Color.secondary : Color.primary)) + + Spacer() + + Text(account.login) + .lineLimit(1) + .foregroundStyle(.secondary) + } + } + .contextMenu(forSelectionType: HotlineAccount.self) { items in + Button { + if let item = items.first { + self.editAccount(item) } + } label: { + Label("Edit Account...", systemImage: "pencil") } - .frame(maxWidth: .infinity) + .disabled(items.isEmpty) Divider() - HStack() { - Button("Revert") { - if let selection { - pendingAccess = selection.access - pendingName = selection.name - pendingLogin = selection.login - - if selection.password != nil { - pendingPassword = selection.password! - } - } + Button(role: .destructive) { + if let item = items.first { + self.deleteAccount(item) } - .controlSize(.large) - .frame(minWidth: 75) - .disabled(!self.isSaveable()) -// .padding() - - Spacer() - - Button(action: { - guard let selection else { - return - } - - // Update existing account - if selection.persisted == true { - - if pendingPassword == placeholderPassword { - Task { @MainActor in - try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: nil, access: pendingAccess.rawValue) - } - } else { - Task { @MainActor in - try? await model.setUser(name: pendingName, login: pendingLogin, newLogin: nil, password: pendingPassword, access: pendingAccess.rawValue) - } - } - - } else { - // Create new existing account - Task { @MainActor in - 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) - .disabled(!self.isSaveable()) + } label: { + Label("Delete Account...", systemImage: "trash") + } + .disabled(items.isEmpty) + } primaryAction: { items in + if let account = items.first { + self.editAccount(account) } - .padding() } - - } - - var accountList: some View { - List(accounts, id: \.self, selection: $selection) { account in - HStack(spacing: 5) { - if account.access.contains(.canDisconnectUsers) { - Image("User Admin") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.25) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(Color.hotlineRed) + .alert("Are you sure you want to delete the \"\(self.accountToDelete?.name ?? "unknown")\" account?", isPresented: self.$deleteConfirm, actions: { + Button("Delete", role: .destructive) { + guard let account = self.accountToDelete else { + return } - else if account.access.rawValue == 0 { - Image("User") - .frame(width: 16, height: 16) - // .padding(.leading, 4) - Text(account.login) - .foregroundStyle(.secondary) + + self.accountToDelete = nil + + Task { + self.selection = nil + + if account.persisted { + try await self.model.deleteUser(login: account.login) + } + + self.accounts = self.accounts.filter { $0.id != account.id } + self.deleteConfirm = false } - // else if account.persisted == false { - // HStack { - // Image("User") - // .frame(width: 16, height: 16) - //// .padding(.leading, 4) - // Text(account.login) - // .italic() - // } - // } - else { - Image("User") - .frame(width: 16, height: 16) - .opacity(account.persisted ? 1.0 : 0.5) - // .padding(.leading, 4) - Text(account.login) + } + }, message: { + Text("You cannot undo this action.") + }) + .sheet(item: self.$accountToEdit) { account in + AccountDetailsView(account: account) { editedAccount in + if let i = self.accounts.firstIndex(of: editedAccount) { + self.accounts.remove(at: i) + self.accounts.insert(editedAccount, at: i) } + self.accounts.sort { $0.name < $1.name } + self.selection = editedAccount + self.accountToEdit = nil } + .id(account.id) + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) } - .frame(width: 250) - .sheet(item: $toDelete) { item in - Form { - HStack{ - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 30)) - Text("Delete account \"\(item.name)\" and all associated files?") - .lineSpacing(4) + .sheet(isPresented: self.$creatorShown) { + AccountDetailsView { newAccount in + self.accounts.append(newAccount) + self.accounts.sort { $0.name < $1.name } + self.selection = newAccount + } + .environment(self.model) + .frame(width: 480) + .frame(minHeight: 300, idealHeight: 400) + .presentationSizing(.fitted) + } + } +} + + +struct AccountDetailsView: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State var account: HotlineAccount = HotlineAccount("Untitled Account", "", HotlineUserAccessOptions.defaultAccess) + + let saved: ((HotlineAccount) -> Void)? + + @State private var password: String = "" + @State private var saving: Bool = false + + var body: some View { + self.accountDetails + .onAppear { + // Display a placeholder for accounts that have been saved to the server + // because we don't have the account password on hand to display. + if self.account.persisted { + self.password = PASSWORD_PLACEHOLDER } } - .frame(minWidth: 300, idealWidth: 450, maxWidth: .infinity, minHeight: 100, idealHeight: 100, maxHeight: .infinity) .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - toDelete = nil + Button { + self.dismiss() + } label: { + Text("Cancel") } } - ToolbarItem(placement: .primaryAction) { - Button(action: { - guard let userToDelete = toDelete else { - return - } - - self.toDelete = nil - self.selection = nil - - if userToDelete.persisted { - Task { @MainActor in - try? await model.deleteUser(login: userToDelete.login) + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + do { + try await self.save() + self.dismiss() + } + catch { + print("ERROR SAVING ACCOUNT: \(error)") } } - - accounts = accounts.filter { $0.login != userToDelete.login } - - }, label: { - Text("Delete") - }) + } label: { + Text(self.account.persisted ? "Save" : "Create") + } + .disabled(self.saving) } } - } } - - private func isSaveable() -> Bool { - guard let selection else { - return false - } + private func save() async throws { + self.saving = true + defer { self.saving = false } - // Disable save if login field is cleared - if pendingLogin == "" { - return false + var accountName: String = self.account.name + if accountName.isBlank { + accountName = DEFAULT_ACCOUNT_NAME } - // If the account initial has a password and it was updated - if selection.password != nil && pendingPassword != placeholderPassword { - return true + // Update existing account + if self.account.persisted { + if self.password == PASSWORD_PLACEHOLDER { + try await self.model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: nil, access: self.account.access.rawValue) + } else { + try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + } + + } else { + // Create new existing account + try await model.createUser(name: accountName, login: self.account.login, password: self.password, access: self.account.access.rawValue) + +// self.password = PASSWORD_PLACEHOLDER + self.account.persisted = true } - // If the account initial has no password, but was updated to have one - if selection.password == nil && pendingPassword != "" { - return true + self.account.name = accountName + self.saved?(self.account) + } + + var accountDetails: some View { + Form { + Section { + TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { + Text("Account") + } + } + + Section { + TextField("Login", text: self.$account.login, prompt: Text("Required")) + .disabled(self.account.persisted) + + if self.account.persisted { + SecureField("Password", text: self.$password, prompt: Text("Optional")) + } else { + TextField("Password", text: self.$password, prompt: Text("Optional")) + } + } + + Section("Files") { + Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) + .disabled(self.model.access?.contains(.canDownloadFiles) == false) + Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) + .disabled(model.access?.contains(.canDownloadFolders) == false) + Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) + .disabled(model.access?.contains(.canUploadFiles) == false) + Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) + .disabled(model.access?.contains(.canUploadFolders) == false) + Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) + .disabled(model.access?.contains(.canUploadAnywhere) == false) + Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) + .disabled(model.access?.contains(.canDeleteFiles) == false) + Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) + .disabled(model.access?.contains(.canRenameFiles) == false) + Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) + .disabled(model.access?.contains(.canMoveFiles) == false) + Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) + .disabled(model.access?.contains(.canSetFileComment) == false) + Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) + .disabled(model.access?.contains(.canCreateFolders) == false) + Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) + .disabled(model.access?.contains(.canDeleteFolders) == false) + Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) + .disabled(model.access?.contains(.canRenameFolders) == false) + Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) + .disabled(model.access?.contains(.canMoveFolders) == false) + Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) + .disabled(model.access?.contains(.canSetFolderComment) == false) + Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) + .disabled(model.access?.contains(.canViewDropBoxes) == false) + Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) + .disabled(model.access?.contains(.canMakeAliases) == false) + } + + Section("User Maintenance") { + Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) + .disabled(model.access?.contains(.canCreateUsers) == false) + Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) + .disabled(model.access?.contains(.canDeleteUsers) == false) + Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) + .disabled(model.access?.contains(.canOpenUsers) == false) + Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) + .disabled(model.access?.contains(.canModifyUsers) == false) + Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) + .disabled(model.access?.contains(.canGetClientInfo) == false) + + Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) + .disabled(model.access?.contains(.canDisconnectUsers) == false) + Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) + .disabled(model.access?.contains(.cantBeDisconnected) == false) + } + + Section("Messaging") { + Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) + .disabled(model.access?.contains(.canSendMessages) == false) + Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) + .disabled(model.access?.contains(.canBroadcast) == false) + } + + Section("News") { + Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) + .disabled(model.access?.contains(.canReadMessageBoard) == false) + Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) + .disabled(model.access?.contains(.canPostMessageBoard) == false) + Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) + .disabled(model.access?.contains(.canDeleteNewsArticles) == false) + Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) + .disabled(model.access?.contains(.canCreateNewsCategories) == false) + Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) + .disabled(model.access?.contains(.canDeleteNewsCategories) == false) + Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) + .disabled(model.access?.contains(.canCreateNewsFolders) == false) + Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) + .disabled(model.access?.contains(.canDeleteNewsFolders) == false) + } + + Section("Chat") { + Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) + .disabled(model.access?.contains(.canCreateChat) == false) + Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) + .disabled(model.access?.contains(.canReadChat) == false) + Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) + .disabled(model.access?.contains(.canSendChat) == false) + } + + Section("Miscellaneous") { + Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) + .disabled(model.access?.contains(.canUseAnyName) == false) + Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) + .disabled(model.access?.contains(.canSkipAgreement) == false) + } } - - // If the access bits or user name have been changed - return pendingAccess.rawValue != selection.access.rawValue || selection.name != pendingName + .disabled(self.model.access?.contains(.canModifyUsers) == false) + .formStyle(.grouped) } } diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index d798fd4..2118518 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -13,8 +13,14 @@ struct FileDetailsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - FileIconView(filename: fd.name, fileType: nil) - .frame(width: 32, height: 32) + if self.fd.type == "Folder" { + FolderIconView() + .frame(width: 32, height: 32) + } + else { + FileIconView(filename: fd.name, fileType: nil) + .frame(width: 32, height: 32) + } TextField("", text: $filename) .disabled(!self.canRename()) } diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index a9b0b83..2386d2a 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -12,8 +12,8 @@ struct FilesView: View { @State private var searchText: String = "" @State private var isSearching: Bool = false @State private var dragOver: Bool = false - @State private var deleteConfirmationDisplayed: Bool = false - @State private var newFolderSheetDisplayed: Bool = false + @State private var confirmDeleteShown: Bool = false + @State private var newFolderShown: Bool = false var body: some View { NavigationStack { @@ -96,7 +96,7 @@ struct FilesView: View { Divider() Button { - self.deleteConfirmationDisplayed = true + self.confirmDeleteShown = true } label: { Label("Delete...", systemImage: "trash") } @@ -200,16 +200,21 @@ struct FilesView: View { ToolbarItem { Button { - self.newFolderSheetDisplayed = true + self.newFolderShown = true } label: { Label("New Folder", systemImage: "folder.badge.plus") } .help("New Folder") + .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { + NewFolderPopover { folderName in + self.newFolder(name: folderName, parent: self.selection) + } + } } ToolbarItem { Button { - self.deleteConfirmationDisplayed = true + self.confirmDeleteShown = true } label: { Label("Delete", systemImage: "trash") } @@ -218,7 +223,7 @@ struct FilesView: View { } } } - .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$deleteConfirmationDisplayed, actions: { + .alert("Are you sure you want to permanently delete \"\(self.selection?.name ?? "this file")\"?", isPresented: self.$confirmDeleteShown, actions: { Button("Delete", role: .destructive) { if let s = self.selection { Task { @@ -229,11 +234,6 @@ struct FilesView: View { }, message: { Text("You cannot undo this action.") }) - .sheet(isPresented: self.$newFolderSheetDisplayed) { - NewFolderSheet { folderName in - self.newFolder(name: folderName, parent: self.selection) - } - } .sheet(item: self.$fileDetails) { item in FileDetailsSheet(fd: item) } @@ -441,9 +441,7 @@ struct FilesView: View { @MainActor private func getFileInfo(_ file: FileInfo) { Task { if let fileInfo = try? await model.getFileDetails(file.name, path: file.path) { - Task { @MainActor in - self.fileDetails = fileInfo - } + self.fileDetails = fileInfo } } } diff --git a/Hotline/macOS/Files/NewFolderPopover.swift b/Hotline/macOS/Files/NewFolderPopover.swift new file mode 100644 index 0000000..4e282f5 --- /dev/null +++ b/Hotline/macOS/Files/NewFolderPopover.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct NewFolderPopover: View { + @Environment(\.dismiss) private var dismiss + + let action: ((String) -> Void)? + + @State private var folderName: String = "Untitled Folder" + + var body: some View { + VStack(spacing: 16) { + TextField("Folder Name", text: self.$folderName) + .onSubmit(of: .text) { + self.createFolder() + } + + HStack(spacing: 8) { + Spacer() + + Button("Cancel", role: .cancel) { + self.dismiss() + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + + if #available(macOS 26.0, *) { + Button("New Folder", role: .confirm) { + self.createFolder() + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + else { + Button("OK") { + self.dismiss() + self.action?(self.folderName) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + } + } + .frame(width: 250) + .padding() + } + + private func createFolder() { + self.dismiss() + self.action?(self.folderName) + } +} diff --git a/Hotline/macOS/Files/NewFolderSheet.swift b/Hotline/macOS/Files/NewFolderSheet.swift deleted file mode 100644 index a899f36..0000000 --- a/Hotline/macOS/Files/NewFolderSheet.swift +++ /dev/null @@ -1,32 +0,0 @@ -import SwiftUI - -struct NewFolderSheet: View { - @Environment(\.dismiss) private var dismiss - - let action: ((String) -> Void)? - - @State private var folderName: String = "Untitled" - - var body: some View { - Form { - TextField(text: self.$folderName) { - Text("Folder Name") - } - } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("New Folder") { - self.dismiss() - self.action?(self.folderName) - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - } - } -} diff --git a/Hotline/macOS/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index a56cd44..6443366 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -114,7 +114,8 @@ struct HotlinePanelView: View { if self.activeHotline?.access?.contains(.canOpenUsers) == true { Button { - self.activeServerState?.selection = .accounts +// self.activeServerState?.selection = .accounts + self.activeServerState?.accountsShown = true } label: { Image("Section Users") @@ -124,7 +125,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Administration") + .help("Manage Server") } Button { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 399deba..2fddee6 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -87,13 +87,14 @@ struct ServerView: View { @State private var connectLogin: String = "" @State private var connectPassword: String = "" @State private var connectionDisplayed: Bool = false +// @State private var accountsShown: Bool = false static var menuItems: [ServerMenuItem] = [ ServerMenuItem(type: .chat, name: "Chat", image: "Section Chat"), ServerMenuItem(type: .board, name: "Board", image: "Section Board"), ServerMenuItem(type: .news, name: "News", image: "Section News"), ServerMenuItem(type: .files, name: "Files", image: "Section Files"), - ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), +// ServerMenuItem(type: .accounts, name: "Accounts", image: "Section Users"), ] static var classicMenuItems: [ServerMenuItem] = [ @@ -110,7 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } - .navigationTitle("Connect to Server") + .navigationTitle("New Connection") } else if self.model.status.isLoggingIn { HStack { @@ -130,7 +131,7 @@ struct ServerView: View { } .frame(maxWidth: 300) .padding() - .navigationTitle("Connecting to Server") + .navigationTitle("New Connection") } else if self.model.status == .loggedIn { self.serverView @@ -153,6 +154,12 @@ struct ServerView: View { .onChange(of: Prefs.shared.automaticMessage) { Task { try? await self.model.sendUserPreferences() } } + .sheet(isPresented: self.$state.accountsShown) { + AccountManagerView() + .environment(self.model) + .frame(width: 400, height: 450) + .presentationSizing(.fitted) + } .toolbar { if #available(macOS 26.0, *) { ToolbarItem(placement: .navigation) { @@ -232,11 +239,11 @@ struct ServerView: View { if menuItem.type == .chat { ListItemView(icon: menuItem.image, title: menuItem.name, unread: model.unreadPublicChat).tag(menuItem.type) } - else if menuItem.type == .accounts { - if model.access?.contains(.canOpenUsers) == true { - ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) - } - } +// else if menuItem.type == .accounts { +// if model.access?.contains(.canOpenUsers) == true { +// ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) +// } +// } else if menuItem.type == .files { ListItemView(icon: menuItem.image, title: menuItem.name, unread: false).tag(menuItem.type) .overlay(alignment: .trailing) { @@ -320,39 +327,51 @@ struct ServerView: View { NavigationSplitView { self.navigationList .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 500) + .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) + .toolbar { + if self.model.access?.contains(.canOpenUsers) == true { + ToolbarItem { + Button { + self.state.accountsShown = true + } label: { + Label("Manage Server", systemImage: "gear") + } + .help("Manage Server") + } + } + } } detail: { switch state.selection { case .chat: ChatView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Public Chat") +// .navigationSubtitle("Public Chat") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Newsgroups") +// .navigationSubtitle("Newsgroups") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Message Board") +// .navigationSubtitle("Message Board") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(model.serverTitle) - .navigationSubtitle("Shared Files") +// .navigationSubtitle("Shared Files") .navigationSplitViewColumnWidth(min: 250, ideal: 500) - case .accounts: - AccountManagerView() - .navigationTitle(model.serverTitle) - .navigationSubtitle("Accounts") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// case .accounts: +// AccountManagerView() +// .navigationTitle(model.serverTitle) +//// .navigationSubtitle("Accounts") +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .user(let userID): - let user = model.users.first(where: { $0.id == userID }) +// let user = model.users.first(where: { $0.id == userID }) MessageView(userID: userID) .navigationTitle(model.serverTitle) - .navigationSubtitle(user?.name ?? "Private Message") +// .navigationSubtitle(user?.name ?? "Private Message") .navigationSplitViewColumnWidth(min: 250, ideal: 500) .onAppear { model.markInstantMessagesAsRead(userID: userID) diff --git a/Hotline/macOS/TransfersView.swift b/Hotline/macOS/TransfersView.swift index 9bef02c..1fefe5a 100644 --- a/Hotline/macOS/TransfersView.swift +++ b/Hotline/macOS/TransfersView.swift @@ -163,7 +163,7 @@ struct TransfersView: View { let result: [(name: String, url: URL)] = intersection.compactMap { url in let appName = FileManager.default .displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name: appName, url: url) }.sorted { $0.name < $1.name } @@ -178,7 +178,7 @@ struct TransfersView: View { if fileURLs.count == 1, let url = NSWorkspace.shared.urlForApplication(toOpen: fileURLs[0]) { let name = FileManager.default .displayName(atPath: url.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name, url) } @@ -213,7 +213,7 @@ struct TransfersView: View { defaultCounts[bestByMajority, default: 0] > 0 { let name = FileManager.default .displayName(atPath: bestByMajority.path) - .replacingOccurrences(of: ".app", with: "") + .replacing(".app", with: "") return (name, bestByMajority) } -- cgit From 8d4cdce6ec00db6b329a3b9ae71cfc37cd642e9b Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 20:15:46 -0800 Subject: You can now save file details (rename files/folders and comments) --- Hotline/Hotline/HotlineClient.swift | 23 ++++++++ Hotline/State/HotlineState.swift | 30 ++++++---- Hotline/macOS/Files/FileDetailsSheet.swift | 90 ++++++++++++++++++------------ Hotline/macOS/Files/FilesView.swift | 2 +- Hotline/macOS/ServerView.swift | 16 +++--- 5 files changed, 107 insertions(+), 54 deletions(-) (limited to 'Hotline/macOS/Files/FileDetailsSheet.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 6dbb460..98cf222 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -645,6 +645,29 @@ public actor HotlineClient { modified: fileModifyDate ) } + + /// Set a file's information (name/comment) + /// + /// - Parameters: + /// - name: File name + /// - path: Directory path containing the file + /// - newName: Name to set the file to + /// - comment: Comment to set on the file + public func setFileInfo(name: String, path: [String], newName: String? = nil, comment: String? = nil, encoding: String.Encoding = .utf8) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .setFileInfo) + transaction.setFieldString(type: .fileName, val: name) + transaction.setFieldPath(type: .filePath, val: path) + + if let newName { + transaction.setFieldString(type: .fileNewName, val: newName) + } + + if let comment { + transaction.setFieldString(type: .fileComment, val: comment) + } + + try await sendTransaction(transaction) + } /// Delete a file or folder /// diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index c9810ad..c632f91 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -789,7 +789,6 @@ class HotlineState: Equatable { // MARK: - Users - @MainActor func getUserList() async throws { guard let client = self.client else { throw HotlineClientError.notConnected @@ -801,7 +800,6 @@ class HotlineState: Equatable { // MARK: - Files - @MainActor @discardableResult func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { guard let client = self.client else { @@ -832,7 +830,6 @@ class HotlineState: Equatable { return newFiles } - @MainActor func getFileDetails(_ fileName: String, path: [String]) async throws -> FileDetails? { guard let client = self.client else { throw HotlineClientError.notConnected @@ -846,7 +843,26 @@ class HotlineState: Equatable { return try await client.getFileInfo(name: fileName, path: fullPath) } - @MainActor + @discardableResult + func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?) async throws -> Bool { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + try await client.setFileInfo(name: fileName, path: filePath, newName: fileNewName, comment: comment) + self.invalidateFileListCache(for: filePath, includingAncestors: true) + return true + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return false + } + + @discardableResult func newFolder(name: String, parentPath: [String]) async throws -> Bool { guard let client = self.client else { throw HotlineClientError.notConnected @@ -1404,12 +1420,6 @@ class HotlineState: Equatable { } } - func setFileInfo(fileName: String, path filePath: [String], fileNewName: String?, comment: String?, encoding: String.Encoding = .utf8) { - // TODO: Implement setFileInfo in HotlineClient - // This method updates file metadata (name and/or comment) - print("setFileInfo not yet implemented in HotlineState/HotlineClient") - } - @MainActor func previewFile(_ fileName: String, path: [String], complete callback: ((PreviewFileInfo?) -> Void)? = nil) { guard let client = self.client else { diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index 2118518..e86bf28 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -3,34 +3,35 @@ import SwiftUI struct FileDetailsSheet: View { @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.presentationMode) var presentationMode + @Environment(\.dismiss) private var dismiss - var fd: FileDetails + var details: FileDetails + @State private var saving: Bool = false @State private var comment: String = "" @State private var filename: String = "" var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - if self.fd.type == "Folder" { + if self.details.type == "Folder" { FolderIconView() .frame(width: 32, height: 32) } else { - FileIconView(filename: fd.name, fileType: nil) + FileIconView(filename: self.details.name, fileType: nil) .frame(width: 32, height: 32) } - TextField("", text: $filename) - .disabled(!self.canRename()) + TextField("File Name", text: $filename) + .disabled(!self.canRename) } let rows: [(String, String)] = [ - ("Type", fd.type), - ("Creator", fd.creator), - ("Size", self.formattedSize(byteCount: fd.size)), - ("Created", Self.dateFormatter.string(from: fd.created)), - ("Modified", Self.dateFormatter.string(from: fd.modified)) + ("Type", self.details.type), + ("Creator", self.details.creator), + ("Size", self.formattedSize(byteCount: self.details.size)), + ("Created", Self.dateFormatter.string(from: self.details.created)), + ("Modified", Self.dateFormatter.string(from: self.details.modified)) ] Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { @@ -54,46 +55,65 @@ struct FileDetailsSheet: View { .font(.body) .lineLimit(10, reservesSpace: true) .padding(.leading, 32 + 16) - .disabled(!self.canSetComment()) + .disabled(!self.canSetComment) } .padding(.vertical, 24) .padding(.horizontal, 24) .frame(width: 400) .toolbar { + if self.saving { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { - presentationMode.wrappedValue.dismiss() + self.dismiss() } } ToolbarItem(placement: .primaryAction) { Button{ var editedFilename: String? - if filename != fd.name { - editedFilename = filename + if self.filename != self.details.name { + editedFilename = self.filename } var editedComment: String? - if comment != fd.comment { - editedComment = comment + if self.comment != self.details.comment { + editedComment = self.comment } - 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 + Task { + self.saving = true + defer { self.saving = false } + + if editedComment != nil || editedFilename != nil { + if try await self.model.setFileInfo(fileName: self.details.name, path: self.details.path, fileNewName: editedFilename, comment: editedComment) { + try await self.model.getFileList(path: self.details.path) + } + } + + // We dismiss even if there is an error for now + // This is not ideal as we may lose a user's written comment + // or new file name, but SwiftUI doesn't show the current + // alert above this sheet so we'll need a different way of + // handling errors to make this work. Until then... + self.dismiss() + } } label: { Text("Save") - }.disabled(!isEdited()) + } } } .onAppear { - self.filename = fd.name - self.comment = fd.comment + self.filename = self.details.name + self.comment = self.details.comment } } - static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() dateFormatter.dateStyle = .long @@ -124,24 +144,24 @@ struct FileDetailsSheet: View { } private func isEdited() -> Bool { - return self.filename != fd.name || self.comment != fd.comment + return self.filename != self.details.name || self.comment != self.details.comment } - private func canRename() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canRenameFolders) == true + private var canRename: Bool { + if self.details.type == "fldr" || self.details.type == "Folder" { + return self.model.access?.contains(.canRenameFolders) == true } - return model.access?.contains(.canRenameFiles) == true + return self.model.access?.contains(.canRenameFiles) == true } - private func canSetComment() -> Bool { - if self.fd.type == "fldr" { - return model.access?.contains(.canSetFolderComment) == true + private var canSetComment: Bool { + if self.details.type == "fldr" || self.details.type == "Folder" { + return self.model.access?.contains(.canSetFolderComment) == true } - return model.access?.contains(.canSetFileComment) == true + return self.model.access?.contains(.canSetFileComment) == true } } //#Preview { -// FileDetailsView(fd: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) +// FileDetailsView(details: FileDetails(name: "AppleWorks 6.sit", path: [""], size: 24601664, comment: "test comment", type: "SITD", creator: "SIT!", created: Date.now, modified: Date.now )) //} diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 325af8c..1ae99d9 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -235,7 +235,7 @@ struct FilesView: View { Text("You cannot undo this action.") }) .sheet(item: self.$fileDetails) { item in - FileDetailsSheet(fd: item) + FileDetailsSheet(details: item) } .fileImporter(isPresented: $uploadFileSelectorDisplayed, allowedContentTypes: [.data, .folder], allowsMultipleSelection: false, onCompletion: { results in switch results { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index b8297fd..3794018 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -196,14 +196,6 @@ struct ServerView: View { .onChange(of: self.model.serverTitle) { self.state.serverName = self.model.serverTitle } - .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { - Button("OK") {} - } message: { - if let message = self.model.errorMessage, - !message.isBlank { - Text(message) - } - } .onAppear { var address = self.server.address if self.server.port != HotlinePorts.DefaultServerPort { @@ -220,6 +212,14 @@ struct ServerView: View { self.connectionDisplayed = true } + .alert("Something Went Wrong", isPresented: self.$model.errorDisplayed) { + Button("OK") {} + } message: { + if let message = self.model.errorMessage, + !message.isBlank { + Text(message) + } + } .focusedSceneValue(\.activeHotlineModel, model) .focusedSceneValue(\.activeServerState, state) } -- cgit From da1d7001f5132115bfdbe19cd95e73b04ba76c95 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 09:56:46 -0800 Subject: Add kick and improve client info display. Also improve error messages when can't connect (server down) or can't login (show server message). --- Hotline.xcodeproj/project.pbxproj | 16 ++++---- Hotline/Hotline/HotlineClient.swift | 27 +++++++++++- Hotline/Hotline/HotlineProtocol.swift | 5 +++ Hotline/MacApp.swift | 6 +-- Hotline/State/HotlineState.swift | 49 +++++++++++++++++++--- Hotline/macOS/BroadcastMessageSheet.swift | 66 ++++++++++++++++++++++++++++++ Hotline/macOS/BroadcastMessageView.swift | 66 ------------------------------ Hotline/macOS/Files/FileDetailsSheet.swift | 43 ++++++++++--------- Hotline/macOS/Files/FilesView.swift | 38 +++++++++-------- Hotline/macOS/MessageView.swift | 36 ++++++++++++++-- Hotline/macOS/ServerView.swift | 23 +++++------ Hotline/macOS/UserClientInfoSheet.swift | 25 +++++++++++ Hotline/macOS/UserInfo.swift | 25 ----------- 13 files changed, 264 insertions(+), 161 deletions(-) create mode 100644 Hotline/macOS/BroadcastMessageSheet.swift delete mode 100644 Hotline/macOS/BroadcastMessageView.swift create mode 100644 Hotline/macOS/UserClientInfoSheet.swift delete mode 100644 Hotline/macOS/UserInfo.swift (limited to 'Hotline/macOS/Files/FileDetailsSheet.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 2b3c8e9..6ff1f66 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -89,7 +89,7 @@ DAB4D8842B4CABEF0048A05C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4D8832B4CABEF0048A05C /* Extensions.swift */; }; DAB4D8872B4CB3610048A05C /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = DAB4D8862B4CB3610048A05C /* MarkdownUI */; }; DABAFBEA2EC58C170015E889 /* AccountDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */; }; - DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */; }; + DABAFBEC2EC599700015E889 /* BroadcastMessageSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */; }; DABE8BF42B55DC0A00884D28 /* transfer-complete.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */; }; DABE8BF62B55DC2E00884D28 /* chat-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF52B55DC2E00884D28 /* chat-message.aiff */; }; DABE8BF82B55DC6100884D28 /* logged-in.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BF72B55DC6100884D28 /* logged-in.aiff */; }; @@ -99,7 +99,7 @@ DABE8C002B55E69800884D28 /* server-message.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8BFF2B55E69800884D28 /* server-message.aiff */; }; DABE8C022B55E69D00884D28 /* new-news.aiff in Resources */ = {isa = PBXBuildFile; fileRef = DABE8C012B55E69D00884D28 /* new-news.aiff */; }; DABE8C042B57940B00884D28 /* DAKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABE8C032B57940A00884D28 /* DAKeychain.swift */; }; - DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserInfo.swift */; }; + DAC0E4232EC78E1100E999DB /* UserClientInfoSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */; }; DAC3D9832BC33FD000A727C9 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC3D9822BC33FD000A727C9 /* AppState.swift */; }; DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */; }; DAC6B2E22EAEE9FE004E2CBA /* NetSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */; }; @@ -205,7 +205,7 @@ DAB4D8812B4C8FED0048A05C /* FileIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileIconView.swift; sourceTree = ""; }; DAB4D8832B4CABEF0048A05C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; DABAFBE92EC58C170015E889 /* AccountDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsView.swift; sourceTree = ""; }; - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageView.swift; sourceTree = ""; }; + DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastMessageSheet.swift; sourceTree = ""; }; DABE8BF32B55DC0A00884D28 /* transfer-complete.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "transfer-complete.aiff"; sourceTree = ""; }; DABE8BF52B55DC2E00884D28 /* chat-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "chat-message.aiff"; sourceTree = ""; }; DABE8BF72B55DC6100884D28 /* logged-in.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "logged-in.aiff"; sourceTree = ""; }; @@ -215,7 +215,7 @@ DABE8BFF2B55E69800884D28 /* server-message.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "server-message.aiff"; sourceTree = ""; }; DABE8C012B55E69D00884D28 /* new-news.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = "new-news.aiff"; sourceTree = ""; }; DABE8C032B57940A00884D28 /* DAKeychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DAKeychain.swift; sourceTree = ""; }; - DAC0E4222EC78E1100E999DB /* UserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInfo.swift; sourceTree = ""; }; + DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserClientInfoSheet.swift; sourceTree = ""; }; DAC3D9822BC33FD000A727C9 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; DAC6B2DE2EAC6236004E2CBA /* ChatStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatStore.swift; sourceTree = ""; }; DAC6B2E12EAEE9EF004E2CBA /* NetSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetSocket.swift; sourceTree = ""; }; @@ -534,15 +534,15 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( - DAC0E4222EC78E1100E999DB /* UserInfo.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, DAE734F82B2E4185000C56F6 /* ServerView.swift */, DAF5BC6B2EC2727100551E4D /* ConnectView.swift */, DAE735022B30C0BB000C56F6 /* MessageView.swift */, + DAC0E4222EC78E1100E999DB /* UserClientInfoSheet.swift */, DA5268B42EB6840A00DCB941 /* TransfersView.swift */, - DABAFBEB2EC5996B0015E889 /* BroadcastMessageView.swift */, + DABAFBEB2EC5996B0015E889 /* BroadcastMessageSheet.swift */, DA501BE52EBE9520001714F8 /* Trackers */, DA5268A82EB081AF00DCB941 /* Chat */, DA5268A62EB0762300DCB941 /* Board */, @@ -736,12 +736,12 @@ DA501BE72EBE9542001714F8 /* TrackerBookmarkSheet.swift in Sources */, DAB4D87E2B4C8BCA0048A05C /* FilePreviewTextView.swift in Sources */, DA3429B72EBAB1750010784E /* QuickLookPreviewView.swift in Sources */, - DABAFBEC2EC599700015E889 /* BroadcastMessageView.swift in Sources */, + DABAFBEC2EC599700015E889 /* BroadcastMessageSheet.swift in Sources */, DA4F2C012B1A558E00D8ADDC /* ChatView.swift in Sources */, DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, - DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */, + DAC0E4232EC78E1100E999DB /* UserClientInfoSheet.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1440b71..29b7827 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -181,7 +181,16 @@ public actor HotlineClient { // Connect socket print("HotlineClient.connect(): Connecting socket...") - let socket = try await NetSocket.connect(host: host, port: port) + let socket: NetSocket + do { + socket = try await NetSocket.connect(host: host, port: port) + } + catch let socketError as NetSocketError { + if case .failed(_) = socketError { + throw HotlineClientError.connectionFailed(socketError) + } + throw socketError + } print("HotlineClient.connect(): Socket connected") // Perform handshake @@ -590,6 +599,22 @@ public actor HotlineClient { try await socket.send(transaction, endian: .big) } + + /// Force a user to disconnect from the server + /// + /// - Parameters: + /// - userID: Target user ID + /// - options: If specified, temporarily or permanently ban the user + public func disconnectUser(userID: UInt16, options: HotlineUserDisconnectOptions? = nil) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .disconnectUser) + + transaction.setFieldUInt16(type: .userID, val: userID) + if let options { + transaction.setFieldUInt16(type: .options, val: options.rawValue) + } + + try await self.sendTransaction(transaction) + } // MARK: - Agreement diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 92fdfd2..a1a0f31 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -10,6 +10,11 @@ struct HotlinePorts { static let DefaultTrackerPort: Int = 5498 } +public enum HotlineUserDisconnectOptions: UInt16 { + case temporarilyBan = 1 + case permanentlyBan = 2 +} + public struct HotlineUserOptions: OptionSet, Sendable { public let rawValue: UInt16 diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 7997a87..4f6c2af 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -185,13 +185,13 @@ struct Application: App { Server(name: nil, description: nil, address: "") } .modelContainer(self.modelContainer) - .defaultSize(width: 690, height: 760) + .defaultSize(width: 780, height: 640) .defaultPosition(.center) .onChange(of: activeServerState) { - AppState.shared.activeServerState = activeServerState + AppState.shared.activeServerState = self.activeServerState } .onChange(of: activeHotline) { - AppState.shared.activeHotline = activeHotline + AppState.shared.activeHotline = self.activeHotline } .commands { CommandGroup(replacing: .newItem) { diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index e78a780..7baa477 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -174,7 +174,7 @@ class HotlineState: Equatable { self.updateServerTitle() } } - var serverTitle: String = "Server" + var serverTitle: String = "" var username: String = "guest" var iconID: Int = 414 var access: HotlineUserAccessOptions? @@ -361,18 +361,41 @@ class HotlineState: Equatable { self.downloadBanner() } - } catch { + } + catch let clientError as HotlineClientError { + switch clientError { + case .connectionFailed(_): + self.displayError(clientError, message: "This server appears to be offline.") + case .loginFailed(let msg): + self.displayError(clientError, message: msg) + case .serverError(_, let msg): + self.displayError(clientError, message: msg) + default: + self.displayError(clientError) + } + if let client = self.client { + await client.disconnect() + self.client = nil + } + self.status = .disconnected + throw clientError + } + catch { print("HotlineState.login(): Login failed with error: \(error)") if let client = self.client { await client.disconnect() self.client = nil } - self.status = .disconnected // .failed(error.localizedDescription) - self.errorDisplayed = true - self.errorMessage = error.localizedDescription + self.status = .disconnected + self.displayError(error) throw error } } + + private func displayError(_ error: Error, message: String? = nil) { + self.errorDisplayed = true + self.errorMessage = message ?? error.localizedDescription + } /// Disconnect from the server (user-initiated) @MainActor @@ -813,6 +836,20 @@ class HotlineState: Equatable { return nil } + + func disconnectUser(id userID: UInt16, options: HotlineUserDisconnectOptions?) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + try await client.disconnectUser(userID: userID, options: options) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + } // MARK: - Files @@ -1948,7 +1985,7 @@ class HotlineState: Equatable { // MARK: - Utilities func updateServerTitle() { - self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Server" + self.serverTitle = self.serverName ?? self.server?.name ?? self.server?.address ?? "Hotline" } // News helpers diff --git a/Hotline/macOS/BroadcastMessageSheet.swift b/Hotline/macOS/BroadcastMessageSheet.swift new file mode 100644 index 0000000..b3bd7ea --- /dev/null +++ b/Hotline/macOS/BroadcastMessageSheet.swift @@ -0,0 +1,66 @@ +import SwiftUI + +fileprivate let CHARACTER_LIMIT: Int = 255 + +struct BroadcastMessageSheet: View { + @Environment(HotlineState.self) private var model: HotlineState + @Environment(\.dismiss) private var dismiss + + @State private var sending: Bool = false + + private var message: String { + self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + @Bindable var model = self.model + + VStack { + TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(5, reservesSpace: true) + } + .padding(.leading, 32) + .padding(.top, 2) + .overlay(alignment: .topLeading) { + Image("Server Message") + } + .padding(16) + .frame(width: 400) + .toolbar { + if self.sending { + ToolbarItem { + ProgressView() + .controlSize(.small) + } + } + + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + self.dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Broadcast") { + let message = self.message + model.broadcastMessage = "" + + guard !message.isBlank else { + return + } + + Task { + self.sending = true + defer { self.sending = false } + + try await model.sendBroadcast(message) + + self.dismiss() + } + } + .disabled(self.message.isEmpty) + } + } + } +} diff --git a/Hotline/macOS/BroadcastMessageView.swift b/Hotline/macOS/BroadcastMessageView.swift deleted file mode 100644 index a5f6fd2..0000000 --- a/Hotline/macOS/BroadcastMessageView.swift +++ /dev/null @@ -1,66 +0,0 @@ -import SwiftUI - -fileprivate let CHARACTER_LIMIT: Int = 255 - -struct BroadcastMessageView: View { - @Environment(HotlineState.self) private var model: HotlineState - @Environment(\.dismiss) private var dismiss - - @State private var sending: Bool = false - - private var message: String { - self.model.broadcastMessage.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var body: some View { - @Bindable var model = self.model - - VStack { - TextField("Write a message...", text: $model.broadcastMessage, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(5, reservesSpace: true) - } - .padding(.leading, 32) - .padding(.top, 2) - .overlay(alignment: .topLeading) { - Image("Server Message") - } - .padding(16) - .frame(width: 400) - .toolbar { - if self.sending { - ToolbarItem { - ProgressView() - .controlSize(.small) - } - } - - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - self.dismiss() - } - } - - ToolbarItem(placement: .confirmationAction) { - Button("Broadcast") { - let message = self.message - model.broadcastMessage = "" - - guard !message.isBlank else { - return - } - - Task { - self.sending = true - defer { self.sending = false } - - try await model.sendBroadcast(message) - - self.dismiss() - } - } - .disabled(self.message.isEmpty) - } - } - } -} diff --git a/Hotline/macOS/Files/FileDetailsSheet.swift b/Hotline/macOS/Files/FileDetailsSheet.swift index e86bf28..1bfacb2 100644 --- a/Hotline/macOS/Files/FileDetailsSheet.swift +++ b/Hotline/macOS/Files/FileDetailsSheet.swift @@ -14,7 +14,7 @@ struct FileDetailsSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 16){ - if self.details.type == "Folder" { + if self.isFolder { FolderIconView() .frame(width: 32, height: 32) } @@ -22,6 +22,7 @@ struct FileDetailsSheet: View { FileIconView(filename: self.details.name, fileType: nil) .frame(width: 32, height: 32) } + TextField("File Name", text: $filename) .disabled(!self.canRename) } @@ -114,6 +115,28 @@ struct FileDetailsSheet: View { } } + private var isFolder: Bool { + self.details.type == "Folder" || self.details.type == "fldr" + } + + private func isEdited() -> Bool { + return self.filename != self.details.name || self.comment != self.details.comment + } + + private var canRename: Bool { + if self.isFolder { + return self.model.access?.contains(.canRenameFolders) == true + } + return self.model.access?.contains(.canRenameFiles) == true + } + + private var canSetComment: Bool { + if self.isFolder { + return self.model.access?.contains(.canSetFolderComment) == true + } + return self.model.access?.contains(.canSetFileComment) == true + } + static var dateFormatter: DateFormatter = { var dateFormatter = DateFormatter() dateFormatter.dateStyle = .long @@ -142,24 +165,6 @@ struct FileDetailsSheet: View { let formattedByteCount = Self.byteCountSizeFormatter.string(from: NSNumber(value:byteCount)) ?? "0" return "\(FileItemView.byteFormatter.string(fromByteCount: Int64(byteCount))) (\(formattedByteCount) bytes)" } - - private func isEdited() -> Bool { - return self.filename != self.details.name || self.comment != self.details.comment - } - - private var canRename: Bool { - if self.details.type == "fldr" || self.details.type == "Folder" { - return self.model.access?.contains(.canRenameFolders) == true - } - return self.model.access?.contains(.canRenameFiles) == true - } - - private var canSetComment: Bool { - if self.details.type == "fldr" || self.details.type == "Folder" { - return self.model.access?.contains(.canSetFolderComment) == true - } - return self.model.access?.contains(.canSetFileComment) == true - } } //#Preview { diff --git a/Hotline/macOS/Files/FilesView.swift b/Hotline/macOS/Files/FilesView.swift index 1ae99d9..c9c4c24 100644 --- a/Hotline/macOS/Files/FilesView.swift +++ b/Hotline/macOS/Files/FilesView.swift @@ -198,28 +198,32 @@ struct FilesView: View { ToolbarSpacer() } - ToolbarItem { - Button { - self.newFolderShown = true - } label: { - Label("New Folder", systemImage: "folder.badge.plus") - } - .help("New Folder") - .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { - NewFolderPopover { folderName in - self.newFolder(name: folderName, parent: self.selection) + if self.model.access?.contains(.canCreateFolders) == true { + ToolbarItem { + Button { + self.newFolderShown = true + } label: { + Label("New Folder", systemImage: "folder.badge.plus") + } + .help("New Folder") + .popover(isPresented: self.$newFolderShown, arrowEdge: .bottom) { + NewFolderPopover { folderName in + self.newFolder(name: folderName, parent: self.selection) + } } } } - ToolbarItem { - Button { - self.confirmDeleteShown = true - } label: { - Label("Delete", systemImage: "trash") + if self.model.access?.contains(.canDeleteFiles) == true || self.model.access?.contains(.canDeleteFolders) == true { + ToolbarItem { + Button { + self.confirmDeleteShown = true + } label: { + Label("Delete", systemImage: "trash") + } + .disabled(self.selection == nil) + .help("Delete") } - .disabled(self.selection == nil) - .help("Delete") } } } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 6690a88..6208d54 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -8,6 +8,8 @@ struct MessageView: View { @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 @State private var userInfo: HotlineUserClientInfo? + @State private var disconnectConfirmShown: Bool = false + @State private var username: String? @Namespace private var bottomID @FocusState private var focusedField: FocusedField? @@ -27,6 +29,10 @@ struct MessageView: View { self.inputBar } .background(Color(nsColor: .textBackgroundColor)) + .onAppear { + let user = self.model.users.first(where: { $0.id == userID }) + self.username = user?.name + } .toolbar { if self.model.access?.contains(.canGetClientInfo) == true { ToolbarItem { @@ -35,11 +41,30 @@ struct MessageView: View { } label: { Image(systemName: "info.circle") } + .help("View \(self.username ?? "user")'s information") + } + } + + if self.model.access?.contains(.canDisconnectUsers) == true { + ToolbarItem { + Button { + self.disconnectConfirmShown = true + } label: { + Image(systemName: "nosign") + } + .help("Disconnect \(self.username ?? "this user")") } } } .sheet(item: self.$userInfo) { info in - UserInfoView(info: info) + UserClientInfoSheet(info: info) + } + .alert("Are you sure you want to disconnect \(self.username ?? "this user")?", isPresented: self.$disconnectConfirmShown) { + Button("Disconnect", role: .destructive) { + self.disconnectUser() + } + } message: { + Text("They will be disconnected from the server, but may reconnect.") } } @@ -51,10 +76,15 @@ struct MessageView: View { } } + private func disconnectUser() { + Task { + try await self.model.disconnectUser(id: self.userID, options: nil) + } + } + private var inputBar: some View { HStack(alignment: .lastTextBaseline, spacing: 0) { - let user = self.model.users.first(where: { $0.id == userID }) - TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) + TextField("Message \(self.username ?? "")", text: $input, axis: .vertical) .focused($focusedField, equals: .chatInput) .textFieldStyle(.plain) .lineLimit(1...5) diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 3794018..ff92acc 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -111,7 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } - .navigationTitle("New Connection") + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status.isLoggingIn { HStack { @@ -131,7 +131,7 @@ struct ServerView: View { } .frame(maxWidth: 300) .padding() - .navigationTitle("New Connection") + .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status == .loggedIn { self.serverView @@ -155,7 +155,7 @@ struct ServerView: View { Task { try? await self.model.sendUserPreferences() } } .sheet(isPresented: self.$state.broadcastShown) { - BroadcastMessageView() + BroadcastMessageSheet() .environment(self.model) .presentationSizing(.fitted) } @@ -336,8 +336,8 @@ struct ServerView: View { var serverView: some View { NavigationSplitView { self.navigationList - .frame(maxWidth: .infinity) - .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 500) + .navigationSplitViewColumnWidth(200) +// .navigationSplitViewColumnWidth(min: 150, ideal: 200, max: 400) .toolbar(removing: .sidebarToggle) // .toolbar { // if self.model.access?.contains(.canOpenUsers) == true { @@ -357,22 +357,22 @@ struct ServerView: View { ChatView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Public Chat") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Newsgroups") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Message Board") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() .navigationTitle(model.serverTitle) // .navigationSubtitle("Shared Files") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) // case .accounts: // AccountManagerView() // .navigationTitle(model.serverTitle) @@ -383,15 +383,12 @@ struct ServerView: View { MessageView(userID: userID) .navigationTitle(model.serverTitle) // .navigationSubtitle(user?.name ?? "Private Message") - .navigationSplitViewColumnWidth(min: 250, ideal: 500) +// .navigationSplitViewColumnWidth(min: 250, ideal: 500) .onAppear { model.markInstantMessagesAsRead(userID: userID) } } } - .background { - Color.red - } } // MARK: - diff --git a/Hotline/macOS/UserClientInfoSheet.swift b/Hotline/macOS/UserClientInfoSheet.swift new file mode 100644 index 0000000..8fa4db8 --- /dev/null +++ b/Hotline/macOS/UserClientInfoSheet.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct UserClientInfoSheet: View { + @Environment(\.dismiss) private var dismiss + + let info: HotlineUserClientInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.details) + .fontDesign(.monospaced) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding() + } + .frame(width: 350, height: 400) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("OK") { + self.dismiss() + } + } + } + } +} diff --git a/Hotline/macOS/UserInfo.swift b/Hotline/macOS/UserInfo.swift deleted file mode 100644 index 328fc02..0000000 --- a/Hotline/macOS/UserInfo.swift +++ /dev/null @@ -1,25 +0,0 @@ -import SwiftUI - -struct UserInfoView: View { - @Environment(\.dismiss) private var dismiss - - let info: HotlineUserClientInfo - - var body: some View { - ScrollView(.vertical) { - Text(self.info.details) - .fontDesign(.monospaced) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding() - } - .frame(width: 350, height: 400) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("OK") { - self.dismiss() - } - } - } - } -} -- cgit