diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-11-12 21:45:13 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-11-12 21:45:13 -0800 |
| commit | 3795039436f741ec23f65e7cd0639023bac1f1ca (patch) | |
| tree | 1cbaf5c1b37583d9af7f84a075b97d131f93054c /Hotline | |
| parent | 79a37fa509576c94b96ff896550426d8ebfdec67 (diff) | |
More work on account management. Add Broadcast UI.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 11 | ||||
| -rw-r--r-- | Hotline/MacApp.swift | 17 | ||||
| -rw-r--r-- | Hotline/State/HotlineState.swift | 10 | ||||
| -rw-r--r-- | Hotline/State/ServerState.swift | 1 | ||||
| -rw-r--r-- | Hotline/macOS/Accounts/AccountDetailsView.swift | 181 | ||||
| -rw-r--r-- | Hotline/macOS/BroadcastMessageView.swift | 66 | ||||
| -rw-r--r-- | Hotline/macOS/HotlinePanelView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/News/NewsView.swift | 2 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 9 |
9 files changed, 230 insertions, 69 deletions
diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index ee99ed7..8d0e870 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -489,6 +489,17 @@ public actor HotlineClient { } // MARK: - Chat + + /// Broadcast a message to the server + /// + /// - Parameters: + /// - message: Text to send + /// - encoding: Text encoding (default: UTF-8) + public func sendBroadcast(_ message: String, encoding: String.Encoding = .utf8, announce: Bool = false) async throws { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .userBroadcast) + transaction.setFieldString(type: .data, val: message, encoding: encoding) + try await socket.send(transaction, endian: .big) + } /// Send a chat message to the server /// diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 5a84da8..7997a87 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -246,9 +246,9 @@ struct Application: App { Divider() Button("Broadcast Message...") { - // TODO: Implement broadcast message when user is allowed. + activeServerState?.broadcastShown = true } - .disabled(true) + .disabled(activeHotline?.access?.contains(.canBroadcast) != true) .keyboardShortcut(.init("B"), modifiers: .command) Divider() @@ -274,15 +274,12 @@ struct Application: App { .disabled(activeHotline?.status != .loggedIn) .keyboardShortcut(.init("4"), modifiers: .command) - if activeHotline?.access?.contains(.canOpenUsers) == true { - Divider() - - Button("Manage Server...") { - activeServerState?.accountsShown = true - } - .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true ) -// .keyboardShortcut(.init("5"), modifiers: .command) + Divider() + + Button("Manage Accounts...") { + activeServerState?.accountsShown = true } + .disabled(activeHotline?.status != .loggedIn || activeHotline?.access?.contains(.canOpenUsers) != true) } } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 5d6471c..627b6a0 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -184,6 +184,7 @@ class HotlineState: Equatable { var users: [User] = [] // Chat + var broadcastMessage: String = "" var chat: [ChatMessage] = [] var chatInput: String = "" var unreadPublicChat: Bool = false @@ -613,6 +614,15 @@ class HotlineState: Equatable { } // MARK: - Chat + + @MainActor + func sendBroadcast(_ message: String) async throws { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + try await client.sendBroadcast(message) + } @MainActor func sendChat(_ text: String, announce: Bool = false) async throws { diff --git a/Hotline/State/ServerState.swift b/Hotline/State/ServerState.swift index 8f8d3bf..3fe10f9 100644 --- a/Hotline/State/ServerState.swift +++ b/Hotline/State/ServerState.swift @@ -6,6 +6,7 @@ class ServerState: Equatable { var selection: ServerNavigationType var serverName: String? = nil var accountsShown: Bool = false + var broadcastShown: Bool = false // var serverBanner: NSImage? = nil // var bannerBackgroundColor: Color? = nil diff --git a/Hotline/macOS/Accounts/AccountDetailsView.swift b/Hotline/macOS/Accounts/AccountDetailsView.swift index d4eab4e..8514873 100644 --- a/Hotline/macOS/Accounts/AccountDetailsView.swift +++ b/Hotline/macOS/Accounts/AccountDetailsView.swift @@ -2,6 +2,25 @@ import SwiftUI fileprivate let PASSWORD_PLACEHOLDER = "xxxxxxxxxxxxxxxxxx" +enum AccountDetailsError: Error { + case noLogin + case failedToSave + + var alertTitle: String { + switch self { + case .noLogin: "A login is required" + case .failedToSave: "Failed to save account" + } + } + + var alertMessage: String { + switch self { + case .noLogin: "Users with accounts are required to have a login. Please add one and try again." + case .failedToSave: "An error occurred while saving this account. Please try again." + } + } +} + struct AccountDetailsView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.dismiss) private var dismiss @@ -12,9 +31,27 @@ struct AccountDetailsView: View { @State private var password: String = "" @State private var saving: Bool = false + @State private var alertTitle: String = "" + @State private var alertMessage: String = "" + @State private var alertShown: Bool = false var body: some View { - self.accountDetails + self.detailsView + .alert(self.alertTitle, isPresented: self.$alertShown, actions: { + if #available(macOS 26.0, *) { + Button("OK", role: .confirm) { + self.alertShown = false + } + } + else { + Button("OK") { + self.alertShown = false + } + } + + }, message: { + Text(self.alertMessage) + }) .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. @@ -43,10 +80,11 @@ struct AccountDetailsView: View { Task { do { try await self.save() - self.dismiss() } - catch { - print("ERROR SAVING ACCOUNT: \(error)") + catch let error as AccountDetailsError { + self.alertTitle = error.alertTitle + self.alertMessage = error.alertMessage + self.alertShown = true } } } label: { @@ -58,41 +96,58 @@ struct AccountDetailsView: View { } private func save() async throws { + guard !self.account.login.isBlank else { + throw AccountDetailsError.noLogin + } + + self.account.name = self.account.name.trimmingCharacters(in: .whitespacesAndNewlines) + self.account.login = self.account.login.trimmingCharacters(in: .whitespacesAndNewlines) + self.saving = true defer { self.saving = false } + // We create a name var here so we don't see the default account name + // flash in the UI while saving. var accountName: String = self.account.name if accountName.isBlank { accountName = DEFAULT_ACCOUNT_NAME } - // 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) + do { + // 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 { - try await model.setUser(name: accountName, login: self.account.login, newLogin: nil, password: self.password, access: self.account.access.rawValue) + // Create new existing account + try await model.createUser(name: accountName, login: self.account.login, 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 + } + catch { + throw AccountDetailsError.failedToSave } + self.account.persisted = true self.account.name = accountName self.saved?(self.account) } - var accountDetails: some View { + private var isEditable: Bool { + self.model.access?.contains(.canModifyUsers) == true + } + + private var detailsView: some View { Form { Section { TextField(text: self.$account.name, prompt: Text(DEFAULT_ACCOUNT_NAME)) { Text("Account") } } + .disabled(!self.isEditable) Section { TextField("Login", text: self.$account.login, prompt: Text("Required")) @@ -104,101 +159,117 @@ struct AccountDetailsView: View { TextField("Password", text: self.$password, prompt: Text("Optional")) } } + .sectionActions { + HStack { + Spacer() + Text("The following permissions define what users of this account can do on this server. Accounts that can disconnect other users are shown in red.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Spacer() + } + } + .disabled(!self.isEditable) Section("Files") { Toggle("Download Files", isOn: self.$account.access.bind(.canDownloadFiles)) - .disabled(self.model.access?.contains(.canDownloadFiles) == false) +// .disabled(self.model.access?.contains(.canDownloadFiles) == false) Toggle("Download Folders", isOn: self.$account.access.bind(.canDownloadFolders)) - .disabled(model.access?.contains(.canDownloadFolders) == false) +// .disabled(model.access?.contains(.canDownloadFolders) == false) Toggle("Upload Files", isOn: self.$account.access.bind(.canUploadFiles)) - .disabled(model.access?.contains(.canUploadFiles) == false) +// .disabled(model.access?.contains(.canUploadFiles) == false) Toggle("Upload Folders", isOn: self.$account.access.bind(.canUploadFolders)) - .disabled(model.access?.contains(.canUploadFolders) == false) +// .disabled(model.access?.contains(.canUploadFolders) == false) Toggle("Upload Anywhere", isOn: self.$account.access.bind(.canUploadAnywhere)) - .disabled(model.access?.contains(.canUploadAnywhere) == false) +// .disabled(model.access?.contains(.canUploadAnywhere) == false) Toggle("Delete Files", isOn: self.$account.access.bind(.canDeleteFiles)) - .disabled(model.access?.contains(.canDeleteFiles) == false) +// .disabled(model.access?.contains(.canDeleteFiles) == false) Toggle("Rename Files", isOn: self.$account.access.bind(.canRenameFiles)) - .disabled(model.access?.contains(.canRenameFiles) == false) +// .disabled(model.access?.contains(.canRenameFiles) == false) Toggle("Move Files", isOn: self.$account.access.bind(.canMoveFiles)) - .disabled(model.access?.contains(.canMoveFiles) == false) +// .disabled(model.access?.contains(.canMoveFiles) == false) Toggle("Comment Files", isOn: self.$account.access.bind(.canSetFileComment)) - .disabled(model.access?.contains(.canSetFileComment) == false) +// .disabled(model.access?.contains(.canSetFileComment) == false) Toggle("Create Folders", isOn: self.$account.access.bind(.canCreateFolders)) - .disabled(model.access?.contains(.canCreateFolders) == false) +// .disabled(model.access?.contains(.canCreateFolders) == false) Toggle("Delete Folders", isOn: self.$account.access.bind(.canDeleteFolders)) - .disabled(model.access?.contains(.canDeleteFolders) == false) +// .disabled(model.access?.contains(.canDeleteFolders) == false) Toggle("Rename Folders", isOn: self.$account.access.bind(.canRenameFolders)) - .disabled(model.access?.contains(.canRenameFolders) == false) +// .disabled(model.access?.contains(.canRenameFolders) == false) Toggle("Move Folders", isOn: self.$account.access.bind(.canMoveFolders)) - .disabled(model.access?.contains(.canMoveFolders) == false) +// .disabled(model.access?.contains(.canMoveFolders) == false) Toggle("Comment Folders", isOn: self.$account.access.bind(.canSetFolderComment)) - .disabled(model.access?.contains(.canSetFolderComment) == false) +// .disabled(model.access?.contains(.canSetFolderComment) == false) Toggle("View Drop Boxes", isOn: self.$account.access.bind(.canViewDropBoxes)) - .disabled(model.access?.contains(.canViewDropBoxes) == false) +// .disabled(model.access?.contains(.canViewDropBoxes) == false) Toggle("Make Aliases", isOn: self.$account.access.bind(.canMakeAliases)) - .disabled(model.access?.contains(.canMakeAliases) == false) +// .disabled(model.access?.contains(.canMakeAliases) == false) } + .disabled(!self.isEditable) Section("User Maintenance") { Toggle("Create Accounts", isOn: self.$account.access.bind(.canCreateUsers)) - .disabled(model.access?.contains(.canCreateUsers) == false) +// .disabled(model.access?.contains(.canCreateUsers) == false) Toggle("Delete Accounts", isOn: self.$account.access.bind(.canDeleteUsers)) - .disabled(model.access?.contains(.canDeleteUsers) == false) +// .disabled(model.access?.contains(.canDeleteUsers) == false) Toggle("Read Accounts", isOn: self.$account.access.bind(.canOpenUsers)) - .disabled(model.access?.contains(.canOpenUsers) == false) +// .disabled(model.access?.contains(.canOpenUsers) == false) Toggle("Modify Accounts", isOn: self.$account.access.bind(.canModifyUsers)) - .disabled(model.access?.contains(.canModifyUsers) == false) +// .disabled(model.access?.contains(.canModifyUsers) == false) Toggle("Get User Info", isOn: self.$account.access.bind(.canGetClientInfo)) - .disabled(model.access?.contains(.canGetClientInfo) == false) +// .disabled(model.access?.contains(.canGetClientInfo) == false) Toggle("Disconnect Users", isOn: self.$account.access.bind(.canDisconnectUsers)) - .disabled(model.access?.contains(.canDisconnectUsers) == false) +// .disabled(model.access?.contains(.canDisconnectUsers) == false) Toggle("Cannot be Disconnected", isOn: self.$account.access.bind(.cantBeDisconnected)) - .disabled(model.access?.contains(.cantBeDisconnected) == false) +// .disabled(model.access?.contains(.cantBeDisconnected) == false) } + .disabled(!self.isEditable) Section("Messaging") { Toggle("Send Messages", isOn: self.$account.access.bind(.canSendMessages)) - .disabled(model.access?.contains(.canSendMessages) == false) +// .disabled(model.access?.contains(.canSendMessages) == false) Toggle("Broadcast", isOn: self.$account.access.bind(.canBroadcast)) - .disabled(model.access?.contains(.canBroadcast) == false) +// .disabled(model.access?.contains(.canBroadcast) == false) } + .disabled(!self.isEditable) Section("News") { Toggle("Read Articles", isOn: self.$account.access.bind(.canReadMessageBoard)) - .disabled(model.access?.contains(.canReadMessageBoard) == false) +// .disabled(model.access?.contains(.canReadMessageBoard) == false) Toggle("Post Articles", isOn: self.$account.access.bind(.canPostMessageBoard)) - .disabled(model.access?.contains(.canPostMessageBoard) == false) +// .disabled(model.access?.contains(.canPostMessageBoard) == false) Toggle("Delete Articles", isOn: self.$account.access.bind(.canDeleteNewsArticles)) - .disabled(model.access?.contains(.canDeleteNewsArticles) == false) +// .disabled(model.access?.contains(.canDeleteNewsArticles) == false) Toggle("Create Categories", isOn: self.$account.access.bind(.canCreateNewsCategories)) - .disabled(model.access?.contains(.canCreateNewsCategories) == false) +// .disabled(model.access?.contains(.canCreateNewsCategories) == false) Toggle("Delete Categories", isOn: self.$account.access.bind(.canDeleteNewsCategories)) - .disabled(model.access?.contains(.canDeleteNewsCategories) == false) +// .disabled(model.access?.contains(.canDeleteNewsCategories) == false) Toggle("Create News Bundles", isOn: self.$account.access.bind(.canCreateNewsFolders)) - .disabled(model.access?.contains(.canCreateNewsFolders) == false) +// .disabled(model.access?.contains(.canCreateNewsFolders) == false) Toggle("Delete News Bundles", isOn: self.$account.access.bind(.canDeleteNewsFolders)) - .disabled(model.access?.contains(.canDeleteNewsFolders) == false) +// .disabled(model.access?.contains(.canDeleteNewsFolders) == false) } + .disabled(!self.isEditable) Section("Chat") { Toggle("Initiate Private Chat", isOn: self.$account.access.bind(.canCreateChat)) - .disabled(model.access?.contains(.canCreateChat) == false) +// .disabled(model.access?.contains(.canCreateChat) == false) Toggle("Read Chat", isOn: self.$account.access.bind(.canReadChat)) - .disabled(model.access?.contains(.canReadChat) == false) +// .disabled(model.access?.contains(.canReadChat) == false) Toggle("Send Chat", isOn: self.$account.access.bind(.canSendChat)) - .disabled(model.access?.contains(.canSendChat) == false) +// .disabled(model.access?.contains(.canSendChat) == false) } + .disabled(!self.isEditable) Section("Miscellaneous") { Toggle("Use Any Name", isOn: self.$account.access.bind(.canUseAnyName)) - .disabled(model.access?.contains(.canUseAnyName) == false) +// .disabled(model.access?.contains(.canUseAnyName) == false) Toggle("Don't Show Agreement", isOn: self.$account.access.bind(.canSkipAgreement)) - .disabled(model.access?.contains(.canSkipAgreement) == false) +// .disabled(model.access?.contains(.canSkipAgreement) == false) } + .disabled(!self.isEditable) } - .disabled(self.model.access?.contains(.canModifyUsers) == false) .formStyle(.grouped) } } diff --git a/Hotline/macOS/BroadcastMessageView.swift b/Hotline/macOS/BroadcastMessageView.swift new file mode 100644 index 0000000..a5f6fd2 --- /dev/null +++ b/Hotline/macOS/BroadcastMessageView.swift @@ -0,0 +1,66 @@ +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/HotlinePanelView.swift b/Hotline/macOS/HotlinePanelView.swift index 6443366..aa97238 100644 --- a/Hotline/macOS/HotlinePanelView.swift +++ b/Hotline/macOS/HotlinePanelView.swift @@ -125,7 +125,7 @@ struct HotlinePanelView: View { .buttonStyle(.plain) .frame(width: 20, height: 20) .disabled(self.activeServerState == nil) - .help("Manage Server") + .help("Manage Accounts") } Button { diff --git a/Hotline/macOS/News/NewsView.swift b/Hotline/macOS/News/NewsView.swift index 976a985..a07a441 100644 --- a/Hotline/macOS/News/NewsView.swift +++ b/Hotline/macOS/News/NewsView.swift @@ -138,7 +138,7 @@ struct NewsView: View { ContentUnavailableView { Label("No News", systemImage: "newspaper") } description: { - Text("This server has not created any newsgroups yet") + Text("This server has no newsgroups") } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index 2fddee6..6184749 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -154,6 +154,11 @@ struct ServerView: View { .onChange(of: Prefs.shared.automaticMessage) { Task { try? await self.model.sendUserPreferences() } } + .sheet(isPresented: self.$state.broadcastShown) { + BroadcastMessageView() + .environment(self.model) + .presentationSizing(.fitted) + } .sheet(isPresented: self.$state.accountsShown) { AccountManagerView() .environment(self.model) @@ -334,9 +339,9 @@ struct ServerView: View { Button { self.state.accountsShown = true } label: { - Label("Manage Server", systemImage: "gear") + Label("Manage Accounts", systemImage: "gear") } - .help("Manage Server") + .help("Manage Accounts") } } } |