aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-12 21:45:13 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-12 21:45:13 -0800
commit3795039436f741ec23f65e7cd0639023bac1f1ca (patch)
tree1cbaf5c1b37583d9af7f84a075b97d131f93054c /Hotline
parent79a37fa509576c94b96ff896550426d8ebfdec67 (diff)
More work on account management. Add Broadcast UI.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Hotline/HotlineClient.swift11
-rw-r--r--Hotline/MacApp.swift17
-rw-r--r--Hotline/State/HotlineState.swift10
-rw-r--r--Hotline/State/ServerState.swift1
-rw-r--r--Hotline/macOS/Accounts/AccountDetailsView.swift181
-rw-r--r--Hotline/macOS/BroadcastMessageView.swift66
-rw-r--r--Hotline/macOS/HotlinePanelView.swift2
-rw-r--r--Hotline/macOS/News/NewsView.swift2
-rw-r--r--Hotline/macOS/ServerView.swift9
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")
}
}
}