From 87f08cf60a5d7c1cf618463916cbac4dab88e0f8 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 7 Nov 2025 10:19:42 -0800 Subject: Massive refactor of transfer cients (new folder upload implementation), brand new async version of NetSocket, and a rewritten Hotline view model. New cross-server transfers UI. --- Hotline/macOS/MessageView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) (limited to 'Hotline/macOS/MessageView.swift') diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 0a8b071..b0c8baa 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,7 +1,7 @@ import SwiftUI struct MessageView: View { - @Environment(Hotline.self) private var model: Hotline + @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @State private var input: String = "" @@ -75,7 +75,11 @@ struct MessageView: View { .multilineTextAlignment(.leading) .onSubmit { if !self.input.isEmpty { - model.sendInstantMessage(self.input, userID: self.userID) + let message = self.input + let uid = self.userID + Task { + try? await model.sendInstantMessage(message, userID: uid) + } } self.input = "" } @@ -107,5 +111,5 @@ struct MessageView: View { #Preview { ChatView() - .environment(Hotline(trackerClient: HotlineTrackerClient(), client: HotlineClient())) + .environment(HotlineState()) } -- cgit From 7ca2ece66a5d40964f5d640255d1b137433511a9 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Thu, 13 Nov 2025 20:53:39 -0800 Subject: UI to view user client info. --- Hotline/Hotline/HotlineClient.swift | 18 +++ Hotline/State/HotlineState.swift | 16 +++ Hotline/macOS/MessageView.swift | 224 +++++++++++++++++++++++------------- 3 files changed, 177 insertions(+), 81 deletions(-) (limited to 'Hotline/macOS/MessageView.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 98cf222..1394057 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -547,6 +547,24 @@ public actor HotlineClient { try await socket.send(transaction, endian: .big) } + + /// Get information text about a user + /// + /// - Parameters: + /// - userID: Target user ID + public func getClientInfoText(for userID: UInt16) async throws -> (username: String, info: String)? { + var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getClientInfoText) + transaction.setFieldUInt16(type: .userID, val: userID) + + let reply = try await self.sendTransaction(transaction) + + if let username = reply.getField(type: .userName)?.getString(), + let info = reply.getField(type: .data)?.getString() { + return (username: username, info: info) + } + + return nil + } /// Update this client's user info (name, icon, options) /// diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index c632f91..318a468 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -797,6 +797,22 @@ class HotlineState: Equatable { let hotlineUsers = try await client.getUserList() self.users = hotlineUsers.map { User(hotlineUser: $0) } } + + func getClientInfoText(id userID: UInt16) async throws -> (username: String, info: String)? { + guard let client = self.client else { + throw HotlineClientError.notConnected + } + + do { + return try await client.getClientInfoText(for: userID) + } + catch let error as HotlineClientError { + self.errorMessage = error.userMessage + self.errorDisplayed = true + } + + return nil + } // MARK: - Files diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index b0c8baa..861a14a 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,5 +1,37 @@ import SwiftUI +struct UserInfo: Identifiable { + let username: String + let data: String + + var id: String { + self.username + } +} + +struct UserInfoView: View { + @Environment(\.dismiss) private var dismiss + + let info: UserInfo + + var body: some View { + ScrollView(.vertical) { + Text(self.info.data) + .fontDesign(.monospaced) + .textSelection(.enabled) + .padding() + } + .frame(width: 300, height: 400) + .toolbar { + if #available(macOS 26.0, *) { + Button(role: .close) { + self.dismiss() + } + } + } + } +} + struct MessageView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @@ -7,104 +39,134 @@ struct MessageView: View { @State private var input: String = "" @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 + @State private var userInfo: UserInfo? + @Namespace private var bottomID @FocusState private var focusedField: FocusedField? var userID: UInt16 var body: some View { - ScrollViewReader { reader in - VStack(alignment: .leading, spacing: 0) { - - // MARK: Scroll View - GeometryReader { gm in - ScrollView(.vertical) { - LazyVStack(alignment: .leading) { - ForEach(model.instantMessages[userID] ?? [InstantMessage]()) { msg in - HStack(alignment: .firstTextBaseline) { - if msg.direction == .outgoing { - Spacer() - } - - Text(LocalizedStringKey(msg.text)) - .lineSpacing(4) - .multilineTextAlignment(.leading) - .textSelection(.enabled) - .tint(msg.direction == .outgoing ? Color("Outgoing Message Link") : Color("Link Color")) - .foregroundStyle(msg.direction == .outgoing ? Color("Outgoing Message Text") : Color("Incoming Message Text")) - .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)) - .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background")) - .clipShape(RoundedRectangle(cornerRadius: 16)) - - if msg.direction == .incoming { - Spacer() - } - } - .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) - } + VStack(alignment: .leading, spacing: 0) { + + // MARK: Scroll View + self.messageList + + // MARK: Input Divider + Divider() + + // MARK: Input Bar + self.inputBar + } + .background(Color(nsColor: .textBackgroundColor)) + .toolbar { + ToolbarItem { + Button { + self.getUserInfo() + } label: { + Image(systemName: "info.circle") + } + } + } + .sheet(item: self.$userInfo) { info in + UserInfoView(info: info) + } + } + + private func getUserInfo() { + Task { + if let (username, info) = try await self.model.getClientInfoText(id: self.userID) { + self.userInfo = UserInfo(username: username, data: info) + } + } + } + + 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) + .focused($focusedField, equals: .chatInput) + .textFieldStyle(.plain) + .lineLimit(1...5) + .multilineTextAlignment(.leading) + .onSubmit { + if !self.input.isEmpty { + let message = self.input + let uid = self.userID + Task { + try? await self.model.sendInstantMessage(message, userID: uid) } - .padding() - - VStack(spacing: 0) {}.id(bottomID) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .defaultScrollAnchor(.bottom) - .onChange(of: model.instantMessages[userID]?.count) { - reader.scrollTo(bottomID, anchor: .bottom) - model.markInstantMessagesAsRead(userID: userID) - } - .onAppear { - reader.scrollTo(bottomID, anchor: .bottom) - model.markInstantMessagesAsRead(userID: userID) - } - .onChange(of: gm.size) { - reader.scrollTo(bottomID, anchor: .bottom) } + self.input = "" } - - // MARK: Input Divider - Divider() - - // MARK: Input Bar - HStack(alignment: .lastTextBaseline, spacing: 0) { - let user = model.users.first(where: { $0.id == userID }) - TextField("Message \(user?.name ?? "")", text: $input, axis: .vertical) - .focused($focusedField, equals: .chatInput) - .textFieldStyle(.plain) - .lineLimit(1...5) - .multilineTextAlignment(.leading) - .onSubmit { - if !self.input.isEmpty { - let message = self.input - let uid = self.userID - Task { - try? await model.sendInstantMessage(message, userID: uid) + .frame(maxWidth: .infinity) + .padding() + } + .frame(maxWidth: .infinity, minHeight: 28) + .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) + .overlay(alignment: .leadingFirstTextBaseline) { + Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + } + .onContinuousHover { phase in + switch phase { + case .active(_): + NSCursor.iBeam.set() + case .ended: + NSCursor.arrow.set() + break + } + } + .onTapGesture { + focusedField = .chatInput + } + } + + private var messageList: some View { + ScrollViewReader { reader in + GeometryReader { gm in + ScrollView(.vertical) { + LazyVStack(alignment: .leading) { + ForEach(self.model.instantMessages[self.userID] ?? [InstantMessage]()) { msg in + HStack(alignment: .firstTextBaseline) { + if msg.direction == .outgoing { + Spacer() + } + + Text(LocalizedStringKey(msg.text)) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .tint(msg.direction == .outgoing ? Color("Outgoing Message Link") : Color("Link Color")) + .foregroundStyle(msg.direction == .outgoing ? Color("Outgoing Message Text") : Color("Incoming Message Text")) + .padding(EdgeInsets(top: 10, leading: 14, bottom: 10, trailing: 14)) + .background(msg.direction == .outgoing ? Color("Outgoing Message Background") : Color("Incoming Message Background")) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if msg.direction == .incoming { + Spacer() } } - self.input = "" + .padding(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) } - .frame(maxWidth: .infinity) - .padding() + } + .padding() + + VStack(spacing: 0) {}.id(bottomID) } - .frame(maxWidth: .infinity, minHeight: 28) - .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) - .overlay(alignment: .leadingFirstTextBaseline) { - Image(systemName: "chevron.right").opacity(0.4).offset(x: 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .defaultScrollAnchor(.bottom) + .onChange(of: self.model.instantMessages[self.userID]?.count) { + reader.scrollTo(self.bottomID, anchor: .bottom) + self.model.markInstantMessagesAsRead(userID: self.userID) } - .onContinuousHover { phase in - switch phase { - case .active(_): - NSCursor.iBeam.set() - case .ended: - NSCursor.arrow.set() - break - } + .onAppear { + reader.scrollTo(self.bottomID, anchor: .bottom) + self.model.markInstantMessagesAsRead(userID: self.userID) } - .onTapGesture { - focusedField = .chatInput + .onChange(of: gm.size) { + reader.scrollTo(self.bottomID, anchor: .bottom) } } - .background(Color(nsColor: .textBackgroundColor)) } } } -- cgit From 46384d99b78cca150aa720eb915d161b5be8c08d Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 08:38:32 -0800 Subject: Cleanup user client info sheet. Hide button if you don't have persmission to do so. --- Hotline.xcodeproj/project.pbxproj | 4 +++ Hotline/Hotline/HotlineClient.swift | 4 +-- Hotline/Hotline/HotlineProtocol.swift | 9 +++++++ Hotline/State/HotlineState.swift | 2 +- Hotline/macOS/MessageView.swift | 50 +++++++---------------------------- Hotline/macOS/UserInfo.swift | 25 ++++++++++++++++++ 6 files changed, 51 insertions(+), 43 deletions(-) create mode 100644 Hotline/macOS/UserInfo.swift (limited to 'Hotline/macOS/MessageView.swift') diff --git a/Hotline.xcodeproj/project.pbxproj b/Hotline.xcodeproj/project.pbxproj index 15453da..2b3c8e9 100644 --- a/Hotline.xcodeproj/project.pbxproj +++ b/Hotline.xcodeproj/project.pbxproj @@ -99,6 +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 */; }; 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 */; }; @@ -214,6 +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 = ""; }; 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 = ""; }; @@ -532,6 +534,7 @@ DAE734F72B2E4126000C56F6 /* macOS */ = { isa = PBXGroup; children = ( + DAC0E4222EC78E1100E999DB /* UserInfo.swift */, DA55AC762BE589F700034857 /* AboutView.swift */, DACCE5E22EABE86A008CDD92 /* AppUpdateView.swift */, DAE136B92B9D1147007D8307 /* HotlinePanelView.swift */, @@ -738,6 +741,7 @@ DA5268B32EB6806E00DCB941 /* HotlineFileDownloadClient.swift in Sources */, DACCE5E32EABE86A008CDD92 /* AppUpdateView.swift in Sources */, DAC6B2E02EAC6236004E2CBA /* ChatStore.swift in Sources */, + DAC0E4232EC78E1100E999DB /* UserInfo.swift in Sources */, DA32CD492B2931640053B98B /* User.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 1394057..1440b71 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -552,7 +552,7 @@ public actor HotlineClient { /// /// - Parameters: /// - userID: Target user ID - public func getClientInfoText(for userID: UInt16) async throws -> (username: String, info: String)? { + public func getClientInfoText(for userID: UInt16) async throws -> HotlineUserClientInfo? { var transaction = HotlineTransaction(id: self.generateTransactionID(), type: .getClientInfoText) transaction.setFieldUInt16(type: .userID, val: userID) @@ -560,7 +560,7 @@ public actor HotlineClient { if let username = reply.getField(type: .userName)?.getString(), let info = reply.getField(type: .data)?.getString() { - return (username: username, info: info) + return HotlineUserClientInfo(username: username, details: info) } return nil diff --git a/Hotline/Hotline/HotlineProtocol.swift b/Hotline/Hotline/HotlineProtocol.swift index 4857d87..92fdfd2 100644 --- a/Hotline/Hotline/HotlineProtocol.swift +++ b/Hotline/Hotline/HotlineProtocol.swift @@ -516,6 +516,15 @@ public class HotlineFile: Identifiable, Hashable { } } +public struct HotlineUserClientInfo: Identifiable { + public var username: String + public var details: String + + public var id: String { + self.username + } +} + public struct HotlineUser: Identifiable, Hashable, Sendable { public let id: UInt16 public let iconID: UInt16 diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 318a468..e78a780 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -798,7 +798,7 @@ class HotlineState: Equatable { self.users = hotlineUsers.map { User(hotlineUser: $0) } } - func getClientInfoText(id userID: UInt16) async throws -> (username: String, info: String)? { + func getClientInfoText(id userID: UInt16) async throws -> HotlineUserClientInfo? { guard let client = self.client else { throw HotlineClientError.notConnected } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 861a14a..6690a88 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,37 +1,5 @@ import SwiftUI -struct UserInfo: Identifiable { - let username: String - let data: String - - var id: String { - self.username - } -} - -struct UserInfoView: View { - @Environment(\.dismiss) private var dismiss - - let info: UserInfo - - var body: some View { - ScrollView(.vertical) { - Text(self.info.data) - .fontDesign(.monospaced) - .textSelection(.enabled) - .padding() - } - .frame(width: 300, height: 400) - .toolbar { - if #available(macOS 26.0, *) { - Button(role: .close) { - self.dismiss() - } - } - } - } -} - struct MessageView: View { @Environment(HotlineState.self) private var model: HotlineState @Environment(\.colorScheme) private var colorScheme @@ -39,7 +7,7 @@ struct MessageView: View { @State private var input: String = "" @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 - @State private var userInfo: UserInfo? + @State private var userInfo: HotlineUserClientInfo? @Namespace private var bottomID @FocusState private var focusedField: FocusedField? @@ -60,11 +28,13 @@ struct MessageView: View { } .background(Color(nsColor: .textBackgroundColor)) .toolbar { - ToolbarItem { - Button { - self.getUserInfo() - } label: { - Image(systemName: "info.circle") + if self.model.access?.contains(.canGetClientInfo) == true { + ToolbarItem { + Button { + self.getUserInfo() + } label: { + Image(systemName: "info.circle") + } } } } @@ -75,8 +45,8 @@ struct MessageView: View { private func getUserInfo() { Task { - if let (username, info) = try await self.model.getClientInfoText(id: self.userID) { - self.userInfo = UserInfo(username: username, data: info) + if let info = try await self.model.getClientInfoText(id: self.userID) { + self.userInfo = info } } } diff --git a/Hotline/macOS/UserInfo.swift b/Hotline/macOS/UserInfo.swift new file mode 100644 index 0000000..328fc02 --- /dev/null +++ b/Hotline/macOS/UserInfo.swift @@ -0,0 +1,25 @@ +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 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/MessageView.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 From a1ec37248ce36e84cbdb896e226f241f39df8292 Mon Sep 17 00:00:00 2001 From: Dustin Mierau Date: Fri, 14 Nov 2025 16:02:56 -0800 Subject: Bit more work on error handling. --- Hotline/Hotline/HotlineClient.swift | 14 ++-- Hotline/Hotline/HotlineExtensions.swift | 94 ++++++++-------------- .../Transfers/HotlineFileUploadClient.swift | 4 +- .../Transfers/HotlineFolderUploadClient.swift | 5 +- .../Hotline/Transfers/HotlineTransferClient.swift | 6 +- Hotline/MacApp.swift | 4 +- Hotline/State/HotlineState.swift | 50 ++++++------ Hotline/macOS/Board/MessageBoardView.swift | 20 +++-- Hotline/macOS/Files/FolderItemView.swift | 2 +- Hotline/macOS/MessageView.swift | 8 +- Hotline/macOS/ServerView.swift | 33 ++------ 11 files changed, 109 insertions(+), 131 deletions(-) (limited to 'Hotline/macOS/MessageView.swift') diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 29b7827..2219330 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -82,10 +82,10 @@ public struct HotlineLogin: Sendable { /// Information about the connected server public struct HotlineServerInfo: Sendable { - let name: String + let name: String? let version: UInt16 - public init(name: String, version: UInt16) { + public init(name: String?, version: UInt16) { self.name = name self.version = version } @@ -242,7 +242,7 @@ public actor HotlineClient { print("HotlineClient.connect(): Starting keep-alive") await client.startKeepAlive() - print("HotlineClient.connect(): Connected to \(serverInfo.name) (v\(serverInfo.version))") + print("HotlineClient.connect(): Connected to \(serverInfo.name ?? "Server") (v\(serverInfo.version))") return client } @@ -279,8 +279,12 @@ public actor HotlineClient { throw HotlineClientError.loginFailed(errorText) } - let serverName = reply.getField(type: .serverName)?.getString() ?? "Unknown" - let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 0 + // All servers send a server version. + let serverVersion = reply.getField(type: .versionNumber)?.getUInt16() ?? 123 + + // Later clients send a name and banner ID. + let serverName = reply.getField(type: .serverName)?.getString() +// let serverBannerID = reply.getField(type: .communityBannerID)?.getInteger() return HotlineServerInfo(name: serverName, version: serverVersion) } diff --git a/Hotline/Hotline/HotlineExtensions.swift b/Hotline/Hotline/HotlineExtensions.swift index bcee812..0d5d806 100644 --- a/Hotline/Hotline/HotlineExtensions.swift +++ b/Hotline/Hotline/HotlineExtensions.swift @@ -490,7 +490,7 @@ extension FileManager { return nil } - guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman) else { + guard let fileName = fileURL.lastPathComponent.data(using: .macOSRoman, allowLossyConversion: true) else { return nil } @@ -826,38 +826,11 @@ extension Array where Element == UInt8 { } func readString(at offset: Int, length: Int) -> String? { - guard let subdata: Data = self.readData(at: offset, length: length) else { + guard let data: Data = self.readData(at: offset, length: length) else { return nil } - if subdata.count == 0 { - return "" - } - - let allowedEncodings = [ - NSUTF8StringEncoding, - NSShiftJISStringEncoding, - NSUnicodeStringEncoding, - NSWindowsCP1251StringEncoding - ] - - var decodedNSString: NSString? - let rawValue = NSString.stringEncoding(for: subdata, encodingOptions: [.allowLossyKey: false], convertedString: &decodedNSString, usedLossyConversion: nil) - - if allowedEncodings.contains(rawValue) { - return decodedNSString as? String - } - - else if rawValue > 1 { - print("ENCODING FOUND \(rawValue)") - } - - var macStr = String(data: subdata, encoding: .macOSRoman) - if macStr == nil { - macStr = String(data: subdata, encoding: .nonLossyASCII) - } - - return macStr + return data.readString(at: 0, length: length) } func readPString(at offset: Int) -> (String?, Int) { @@ -1158,36 +1131,39 @@ extension NetSocket { let length = try await read(UInt8.self) guard length > 0 else { return nil } - let data = try await read(Int(length)) + let dataLength = Int(length) + let data = try await read(dataLength) + + return data.readString(at: 0, length: dataLength) // Try auto-detection with common encodings - let allowedEncodings = [ - String.Encoding.utf8.rawValue, - String.Encoding.shiftJIS.rawValue, - String.Encoding.unicode.rawValue, - String.Encoding.windowsCP1251.rawValue - ] - - var decodedString: NSString? - let detected = NSString.stringEncoding( - for: data, - encodingOptions: [.allowLossyKey: false], - convertedString: &decodedString, - usedLossyConversion: nil - ) - - if allowedEncodings.contains(detected), let str = decodedString as? String { - return str - } - - // Fallback to MacRoman for classic Mac compatibility - guard let str = String(data: data, encoding: .macOSRoman) else { - throw NetSocketError.decodeFailed(NSError( - domain: "NetSocket", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] - )) - } - return str +// let allowedEncodings = [ +// String.Encoding.utf8.rawValue, +// String.Encoding.shiftJIS.rawValue, +// String.Encoding.unicode.rawValue, +// String.Encoding.windowsCP1251.rawValue +// ] +// +// var decodedString: NSString? +// let detected = NSString.stringEncoding( +// for: data, +// encodingOptions: [.allowLossyKey: false], +// convertedString: &decodedString, +// usedLossyConversion: nil +// ) +// +// if allowedEncodings.contains(detected), let str = decodedString as? String { +// return str +// } +// +// // Fallback to MacRoman for classic Mac compatibility +// guard let str = String(data: data, encoding: .macOSRoman) else { +// throw NetSocketError.decodeFailed(NSError( +// domain: "NetSocket", +// code: -1, +// userInfo: [NSLocalizedDescriptionKey: "Failed to decode pascal string with any known encoding"] +// )) +// } +// return str } } diff --git a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift index 384e9bd..937db6a 100644 --- a/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFileUploadClient.swift @@ -131,7 +131,9 @@ public class HotlineFileUploadClient: @MainActor HotlineTransferClient { throw HotlineTransferClientError.failedToTransfer } - let infoForkData = infoFork.data() + guard let infoForkData = infoFork.data() else { + throw HotlineTransferClientError.failedToTransfer + } let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize diff --git a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift index 1947de6..f8dfe02 100644 --- a/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift +++ b/Hotline/Hotline/Transfers/HotlineFolderUploadClient.swift @@ -380,7 +380,10 @@ public class HotlineFolderUploadClient: @MainActor HotlineTransferClient { throw HotlineTransferClientError.failedToTransfer } - let infoForkData = infoFork.data() + guard let infoForkData = infoFork.data() else { + throw HotlineTransferClientError.failedToTransfer + } + let dataForkSize = forkSizes.dataForkSize let resourceForkSize = forkSizes.resourceForkSize diff --git a/Hotline/Hotline/Transfers/HotlineTransferClient.swift b/Hotline/Hotline/Transfers/HotlineTransferClient.swift index 596155b..5f786fc 100644 --- a/Hotline/Hotline/Transfers/HotlineTransferClient.swift +++ b/Hotline/Hotline/Transfers/HotlineTransferClient.swift @@ -260,8 +260,10 @@ struct HotlineFileInfoFork { return nil } - func data() -> Data { - let fileName = self.name.data(using: .macOSRoman)! + func data() -> Data? { + guard let fileName = self.name.data(using: .macOSRoman, allowLossyConversion: true) else { + return nil + } let data = Data(endian: .big) { self.platform diff --git a/Hotline/MacApp.swift b/Hotline/MacApp.swift index 4f6c2af..bf816d7 100644 --- a/Hotline/MacApp.swift +++ b/Hotline/MacApp.swift @@ -184,9 +184,11 @@ struct Application: App { } defaultValue: { Server(name: nil, description: nil, address: "") } - .modelContainer(self.modelContainer) + .windowToolbarStyle(.unified) +// .windowStyle(.hiddenTitleBar) .defaultSize(width: 780, height: 640) .defaultPosition(.center) + .modelContainer(self.modelContainer) .onChange(of: activeServerState) { AppState.shared.activeServerState = self.activeServerState } diff --git a/Hotline/State/HotlineState.swift b/Hotline/State/HotlineState.swift index 7baa477..df8434d 100644 --- a/Hotline/State/HotlineState.swift +++ b/Hotline/State/HotlineState.swift @@ -324,10 +324,10 @@ class HotlineState: Equatable { print("HotlineState.login(): Getting server info...") if let serverInfo = await client.server { self.serverVersion = serverInfo.version - if !serverInfo.name.isEmpty { - self.serverName = serverInfo.name + if let name = serverInfo.name { + self.serverName = name } - print("HotlineState.login(): Server info retrieved: \(serverInfo.name) v\(serverInfo.version)") + print("HotlineState.login(): Server info retrieved: \(self.serverTitle) v\(serverInfo.version)") } self.status = .connected @@ -336,7 +336,7 @@ class HotlineState: Equatable { // Request initial data before starting event loop print("HotlineState.login(): Requesting user list...") try await self.getUserList() - + self.status = .loggedIn print("HotlineState.login(): Status set to loggedIn") @@ -398,7 +398,6 @@ class HotlineState: Equatable { } /// Disconnect from the server (user-initiated) - @MainActor func disconnect() async { print("HotlineState.disconnect(): Called") guard let client = self.client else { @@ -424,7 +423,6 @@ class HotlineState: Equatable { } /// Handle connection closure (server-initiated or after user disconnect) - @MainActor private func handleConnectionClosed() { print("HotlineState: handleConnectionClosed() entered") guard self.client != nil else { @@ -830,8 +828,7 @@ class HotlineState: Equatable { return try await client.getClientInfoText(for: userID) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return nil @@ -846,25 +843,33 @@ class HotlineState: Equatable { try await client.disconnectUser(userID: userID, options: options) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } } // MARK: - Files @discardableResult - func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo] { + func getFileList(path: [String] = [], suppressErrors: Bool = false, preferCache: Bool = false) async throws -> [FileInfo]? { guard let client = self.client else { throw HotlineClientError.notConnected } - + // Check cache first if preferred if preferCache, let cached = self.cachedFileList(for: path, ttl: self.fileSearchConfig.cacheTTL, allowStale: false) { return cached.items } - - let hotlineFiles = try await client.getFileList(path: path) + + let hotlineFiles: [HotlineFile] + do { + hotlineFiles = try await client.getFileList(path: path) + } + catch let error as HotlineClientError { + self.displayError(error, message: error.userMessage) + self.filesLoaded = true + return nil + } + let newFiles = hotlineFiles.map { FileInfo(hotlineFile: $0) } // Update UI state @@ -908,8 +913,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -927,8 +931,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -952,8 +955,7 @@ class HotlineState: Equatable { return true } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) } return false @@ -985,8 +987,7 @@ class HotlineState: Equatable { result = try await client.downloadFile(name: fileName, path: fullPath) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) return } @@ -1351,6 +1352,8 @@ class HotlineState: Equatable { guard let client = self.client else { return } let fileName = fileURL.lastPathComponent + + print("UPLOAD FILE: \(fileName) \(fileURL)") guard fileURL.isFileURL, !fileName.isEmpty else { print("HotlineState: Not a valid file URL") @@ -1381,8 +1384,7 @@ class HotlineState: Equatable { referenceNumber = try await client.uploadFile(name: fileName, path: path) } catch let error as HotlineClientError { - self.errorMessage = error.userMessage - self.errorDisplayed = true + self.displayError(error, message: error.userMessage) return } diff --git a/Hotline/macOS/Board/MessageBoardView.swift b/Hotline/macOS/Board/MessageBoardView.swift index 710ff20..be039d8 100644 --- a/Hotline/macOS/Board/MessageBoardView.swift +++ b/Hotline/macOS/Board/MessageBoardView.swift @@ -8,16 +8,14 @@ struct MessageBoardView: View { var body: some View { NavigationStack { - if self.model.access?.contains(.canReadMessageBoard) != false { - if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { - self.emptyBoardView - } - else { - self.messageBoardView - } + if self.model.access?.contains(.canReadMessageBoard) != true { + self.disabledBoardView + } + else if self.model.messageBoardLoaded && self.model.messageBoard.isEmpty { + self.emptyBoardView } else { - self.disabledBoardView + self.messageBoardView } } .sheet(isPresented: $composerDisplayed) { @@ -32,7 +30,7 @@ struct MessageBoardView: View { } label: { Image(systemName: "square.and.pencil") } - .disabled(self.model.access?.contains(.canPostMessageBoard) == false) + .disabled((self.model.access?.contains(.canPostMessageBoard) != true) || (self.model.access?.contains(.canReadMessageBoard) != true)) .help("Post to Message Board") } } @@ -45,9 +43,9 @@ struct MessageBoardView: View { private var disabledBoardView: some View { ContentUnavailableView { - Label("Message Board Disabled", systemImage: "quote.bubble") + Label("No Message Board", systemImage: "quote.bubble") } description: { - Text("This server has turned off the message board") + Text("This server has turned off their message board") } } diff --git a/Hotline/macOS/Files/FolderItemView.swift b/Hotline/macOS/Files/FolderItemView.swift index 18522af..f82bb11 100644 --- a/Hotline/macOS/Files/FolderItemView.swift +++ b/Hotline/macOS/Files/FolderItemView.swift @@ -20,7 +20,7 @@ struct FolderItemView: View { self.model.uploadFile(url: fileURL, path: filePath) { info in Task { // Refresh file listing to display newly uploaded file. - let _ = try? await model.getFileList(path: filePath) + try? await model.getFileList(path: filePath) } } } diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index 6208d54..858aa37 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -30,9 +30,15 @@ struct MessageView: View { } .background(Color(nsColor: .textBackgroundColor)) .onAppear { - let user = self.model.users.first(where: { $0.id == userID }) + self.model.markInstantMessagesAsRead(userID: self.userID) + + let user = self.model.users.first(where: { $0.id == self.userID }) self.username = user?.name } + + .onAppear { + self.model.markInstantMessagesAsRead(userID: userID) + } .toolbar { if self.model.access?.contains(.canGetClientInfo) == true { ToolbarItem { diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index ff92acc..696e08d 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -111,6 +111,7 @@ struct ServerView: View { self.connectForm Spacer() } + .presentedWindowToolbarStyle(.unified(showsTitle: false)) .navigationTitle(self.model.serverTitle.isBlank ? "Hotline" : self.model.serverTitle) } else if self.model.status.isLoggingIn { @@ -249,6 +250,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 == .board { +// if self.model.access?.contains(.canReadMessageBoard) == 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) @@ -355,40 +361,17 @@ struct ServerView: View { switch state.selection { case .chat: ChatView() - .navigationTitle(model.serverTitle) -// .navigationSubtitle("Public Chat") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() - .navigationTitle(model.serverTitle) -// .navigationSubtitle("Newsgroups") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .board: MessageBoardView() - .navigationTitle(model.serverTitle) -// .navigationSubtitle("Message Board") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .files: FilesView() - .navigationTitle(model.serverTitle) -// .navigationSubtitle("Shared Files") -// .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 }) MessageView(userID: userID) - .navigationTitle(model.serverTitle) -// .navigationSubtitle(user?.name ?? "Private Message") -// .navigationSplitViewColumnWidth(min: 250, ideal: 500) - .onAppear { - model.markInstantMessagesAsRead(userID: userID) - } } } + .navigationTitle(self.model.serverTitle) } // MARK: - @@ -538,7 +521,7 @@ struct ServerTransferRow: View { withAnimation(.snappy(duration: 0.25, extraBounce: 0.3)) { self.hovered = hovered } - self.detailsShown = hovered +// self.detailsShown = hovered } .onTapGesture(count: 2) { guard transfer.completed, let url = transfer.fileURL else { -- cgit