aboutsummaryrefslogtreecommitdiff
path: root/Hotline/macOS
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-12 18:55:25 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-12 18:55:25 -0800
commit5eff79cc4891076d85223dcfd96c7cb8894c80f2 (patch)
tree9a0197cf30d3300cb2fbc024da26ddbc82df43bd /Hotline/macOS
parent5a3d06c527cf03d68149449965fade0dd5ea0ed2 (diff)
Get Accounts out of the sidebar, redesign Account management.
Diffstat (limited to 'Hotline/macOS')
-rw-r--r--Hotline/macOS/Accounts/AccountManagerView.swift686
-rw-r--r--Hotline/macOS/Files/FileDetailsSheet.swift10
-rw-r--r--Hotline/macOS/Files/FilesView.swift26
-rw-r--r--Hotline/macOS/Files/NewFolderPopover.swift51
-rw-r--r--Hotline/macOS/Files/NewFolderSheet.swift32
-rw-r--r--Hotline/macOS/HotlinePanelView.swift5
-rw-r--r--Hotline/macOS/ServerView.swift59
-rw-r--r--Hotline/macOS/TransfersView.swift6
8 files changed, 467 insertions, 408 deletions
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)
}