aboutsummaryrefslogtreecommitdiff
path: root/Hotline
diff options
context:
space:
mode:
authorDustin Mierau <dustin@mierau.me>2025-11-13 20:53:39 -0800
committerDustin Mierau <dustin@mierau.me>2025-11-13 20:53:39 -0800
commit7ca2ece66a5d40964f5d640255d1b137433511a9 (patch)
treea3352708a1a7afbe043433d401ce41793468a88b /Hotline
parent8d4cdce6ec00db6b329a3b9ae71cfc37cd642e9b (diff)
UI to view user client info.
Diffstat (limited to 'Hotline')
-rw-r--r--Hotline/Hotline/HotlineClient.swift18
-rw-r--r--Hotline/State/HotlineState.swift16
-rw-r--r--Hotline/macOS/MessageView.swift224
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))
}
}
}