diff options
| author | Dustin Mierau <dustin@mierau.me> | 2025-11-13 20:53:39 -0800 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2025-11-13 20:53:39 -0800 |
| commit | 7ca2ece66a5d40964f5d640255d1b137433511a9 (patch) | |
| tree | a3352708a1a7afbe043433d401ce41793468a88b /Hotline | |
| parent | 8d4cdce6ec00db6b329a3b9ae71cfc37cd642e9b (diff) | |
UI to view user client info.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 18 | ||||
| -rw-r--r-- | Hotline/State/HotlineState.swift | 16 | ||||
| -rw-r--r-- | Hotline/macOS/MessageView.swift | 224 |
3 files changed, 177 insertions, 81 deletions
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)) } } } |