diff options
| author | Dustin Mierau <dustin@mierau.me> | 2024-05-03 12:49:52 -0700 |
|---|---|---|
| committer | Dustin Mierau <dustin@mierau.me> | 2024-05-03 12:49:52 -0700 |
| commit | 02b452ccaf54cb24eb38fcb752126888994c0a1b (patch) | |
| tree | c30e71a1df363092a369fd7847c6223914662ce4 /Hotline | |
| parent | 73208d70d21db7e526e371fa863acd0327caad9e (diff) | |
First pass at private messages UI.
Diffstat (limited to 'Hotline')
| -rw-r--r-- | Hotline/Assets.xcassets/Incoming Message Background.colorset/Contents.json | 38 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Incoming Message Text.colorset/Contents.json | 38 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Link Color.colorset/Contents.json | 6 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Outgoing Message Background.colorset/Contents.json | 38 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Outgoing Message Link.colorset/Contents.json | 38 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Outgoing Message Text.colorset/Contents.json | 38 | ||||
| -rw-r--r-- | Hotline/Assets.xcassets/Unread Message Dot.colorset/Contents.json | 38 | ||||
| -rw-r--r-- | Hotline/Hotline/HotlineClient.swift | 24 | ||||
| -rw-r--r-- | Hotline/Models/Hotline.swift | 66 | ||||
| -rw-r--r-- | Hotline/Models/InstantMessage.swift | 18 | ||||
| -rw-r--r-- | Hotline/Models/User.swift | 6 | ||||
| -rw-r--r-- | Hotline/Utility/FoundationExtensions.swift | 1 | ||||
| -rw-r--r-- | Hotline/Utility/SoundEffects.swift | 1 | ||||
| -rw-r--r-- | Hotline/macOS/ChatView.swift | 23 | ||||
| -rw-r--r-- | Hotline/macOS/MessageView.swift | 166 | ||||
| -rw-r--r-- | Hotline/macOS/ServerView.swift | 26 |
16 files changed, 439 insertions, 126 deletions
diff --git a/Hotline/Assets.xcassets/Incoming Message Background.colorset/Contents.json b/Hotline/Assets.xcassets/Incoming Message Background.colorset/Contents.json new file mode 100644 index 0000000..c5fd6a0 --- /dev/null +++ b/Hotline/Assets.xcassets/Incoming Message Background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF8", + "green" : "0xF8", + "red" : "0xF8" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.150", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Incoming Message Text.colorset/Contents.json b/Hotline/Assets.xcassets/Incoming Message Text.colorset/Contents.json new file mode 100644 index 0000000..0c600f9 --- /dev/null +++ b/Hotline/Assets.xcassets/Incoming Message Text.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Link Color.colorset/Contents.json b/Hotline/Assets.xcassets/Link Color.colorset/Contents.json index e1d527b..c4149fb 100644 --- a/Hotline/Assets.xcassets/Link Color.colorset/Contents.json +++ b/Hotline/Assets.xcassets/Link Color.colorset/Contents.json @@ -6,7 +6,7 @@ "components" : { "alpha" : "1.000", "blue" : "0xFF", - "green" : "0x86", + "green" : "0x78", "red" : "0x1F" } }, @@ -24,8 +24,8 @@ "components" : { "alpha" : "1.000", "blue" : "0xFF", - "green" : "0xB0", - "red" : "0x6C" + "green" : "0xAA", + "red" : "0x71" } }, "idiom" : "universal" diff --git a/Hotline/Assets.xcassets/Outgoing Message Background.colorset/Contents.json b/Hotline/Assets.xcassets/Outgoing Message Background.colorset/Contents.json new file mode 100644 index 0000000..9a51a31 --- /dev/null +++ b/Hotline/Assets.xcassets/Outgoing Message Background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Outgoing Message Link.colorset/Contents.json b/Hotline/Assets.xcassets/Outgoing Message Link.colorset/Contents.json new file mode 100644 index 0000000..51c9937 --- /dev/null +++ b/Hotline/Assets.xcassets/Outgoing Message Link.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xAA", + "red" : "0x71" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xAA", + "red" : "0x71" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Outgoing Message Text.colorset/Contents.json b/Hotline/Assets.xcassets/Outgoing Message Text.colorset/Contents.json new file mode 100644 index 0000000..2536dc2 --- /dev/null +++ b/Hotline/Assets.xcassets/Outgoing Message Text.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Assets.xcassets/Unread Message Dot.colorset/Contents.json b/Hotline/Assets.xcassets/Unread Message Dot.colorset/Contents.json new file mode 100644 index 0000000..05fcb96 --- /dev/null +++ b/Hotline/Assets.xcassets/Unread Message Dot.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xF1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xF1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Hotline/Hotline/HotlineClient.swift b/Hotline/Hotline/HotlineClient.swift index 5ed71a3..f0e5eea 100644 --- a/Hotline/Hotline/HotlineClient.swift +++ b/Hotline/Hotline/HotlineClient.swift @@ -37,6 +37,7 @@ protocol HotlineClientDelegate: AnyObject { func hotlineReceivedChatMessage(message: String) func hotlineReceivedUserList(users: [HotlineUser]) func hotlineReceivedServerMessage(message: String) + func hotlineReceivedPrivateMessage(userID: UInt16, message: String) func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) func hotlineUserChanged(user: HotlineUser) func hotlineUserDisconnected(userID: UInt16) @@ -49,6 +50,7 @@ extension HotlineClientDelegate { func hotlineReceivedChatMessage(message: String) {} func hotlineReceivedUserList(users: [HotlineUser]) {} func hotlineReceivedServerMessage(message: String) {} + func hotlineReceivedPrivateMessage(userID: UInt16, message: String) {} func hotlineReceivedUserAccess(options: HotlineUserAccessOptions) {} func hotlineUserChanged(user: HotlineUser) {} func hotlineUserDisconnected(userID: UInt16) {} @@ -303,11 +305,12 @@ class HotlineClient: NetSocketDelegate { let userIconID = userIconIDField.getUInt16(), let userFlagsField = packet.getField(type: .userFlags), let userFlags = userFlagsField.getUInt16() { - print("HotlineClient: User changed \(username) icon: \(userIconID)") + print("HotlineClient: User changed \(userID) \(username) icon: \(userIconID)") let user = HotlineUser(id: userID, iconID: userIconID, status: userFlags, name: username) self.delegate?.hotlineUserChanged(user: user) } + case .notifyOfUserDelete: if let userIDField = packet.getField(type: .userID), let userID = userIDField.getUInt16() { @@ -322,7 +325,14 @@ class HotlineClient: NetSocketDelegate { case .serverMessage: if let messageField = packet.getField(type: .data), let message = messageField.getString() { - self.delegate?.hotlineReceivedServerMessage(message: message) + + if let userIDField = packet.getField(type: .userID), + let userID = userIDField.getUInt16() { + self.delegate?.hotlineReceivedPrivateMessage(userID: userID, message: message) + } + else { + self.delegate?.hotlineReceivedServerMessage(message: message) + } } case .showAgreement: @@ -448,6 +458,14 @@ class HotlineClient: NetSocketDelegate { self.sendPacket(t) } + @MainActor func sendInstantMessage(message: String, userID: UInt16, encoding: String.Encoding = .utf8) { + var t = HotlineTransaction(type: .sendInstantMessage) + t.setFieldUInt16(type: .userID, val: userID) + t.setFieldUInt32(type: .options, val: 1) + t.setFieldString(type: .data, val: message, encoding: encoding) + self.sendPacket(t) + } + @MainActor func sendGetUserList() { let t = HotlineTransaction(type: .getUserNameList) self.sendPacket(t) { [weak self] reply, err in @@ -626,7 +644,7 @@ class HotlineClient: NetSocketDelegate { let fileName = reply.getField(type: .fileName)?.getString(), let fileCreator = reply.getField(type: .fileCreatorString)?.getString(), let fileType = reply.getField(type: .fileTypeString)?.getString(), - let fileTypeString = reply.getField(type: .fileTypeString)?.getString(), + let _ = reply.getField(type: .fileTypeString)?.getString(), let fileCreateDate = reply.getField(type: .fileCreateDate)?.data.readDate(at: 0), let fileModifyDate = reply.getField(type: .fileModifyDate)?.data.readDate(at: 0) else { diff --git a/Hotline/Models/Hotline.swift b/Hotline/Models/Hotline.swift index 95a21c6..beed6a3 100644 --- a/Hotline/Models/Hotline.swift +++ b/Hotline/Models/Hotline.swift @@ -108,7 +108,6 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { ] var status: HotlineClientStatus = .disconnected - var server: Server? { didSet { self.updateServerTitle() @@ -125,8 +124,6 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { var iconID: Int = 414 var access: HotlineUserAccessOptions? var agreed: Bool = false - - var currentUser: User? = nil var users: [User] = [] var chat: [ChatMessage] = [] var messageBoard: [String] = [] @@ -135,9 +132,10 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { var filesLoaded: Bool = false var news: [NewsInfo] = [] var newsLoaded: Bool = false - + var instantMessages: [UInt16:[InstantMessage]] = [:] var transfers: [TransferInfo] = [] var downloads: [HotlineFileClient] = [] + var unreadInstantMessages: [UInt16:UInt16] = [:] @ObservationIgnored var bannerClient: HotlineFileClient? #if os(macOS) @@ -211,6 +209,31 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { self.client.sendAgree(username: self.username, iconID: UInt16(self.iconID), options: .none) } + @MainActor func sendInstantMessage(_ text: String, userID: UInt16) { + let message = InstantMessage(direction: .outgoing, text: text, type: .message, date: Date()) + + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [message] + } + else { + self.instantMessages[userID]!.append(message) + } + + self.client.sendInstantMessage(message: text, userID: userID) + + if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + } + + func hasUnreadInstantMessages(userID: UInt16) -> Bool { + return self.unreadInstantMessages[userID] != nil + } + + func markInstantMessagesAsRead(userID: UInt16) { + self.unreadInstantMessages.removeValue(forKey: userID) + } + @MainActor func sendChat(_ text: String) { self.client.sendChat(message: text) } @@ -681,19 +704,43 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { } func hotlineReceivedServerMessage(message: String) { -// print("Hotline: received server message:\n\(message)") -// self.chat.append(ChatMessage(text: message, type: .server, date: Date())) + if Prefs.shared.playChatSound && Prefs.shared.playChatSound { + SoundEffectPlayer.shared.playSoundEffect(.serverMessage) + } + + print("Hotline: received server message:\n\(message)") + self.chat.append(ChatMessage(text: message, type: .server, date: Date())) + } + + func hotlineReceivedPrivateMessage(userID: UInt16, message: String) { + if let existingUserIndex = self.users.firstIndex(where: { $0.id == UInt(userID) }) { + let user = self.users[existingUserIndex] + print("Hotline: received private message from \(user.name): \(message)") + + if Prefs.shared.playPrivateMessageSound && Prefs.shared.playPrivateMessageSound { + SoundEffectPlayer.shared.playSoundEffect(.chatMessage) + } + + let instantMessage = InstantMessage(direction: .incoming, text: message, type: .message, date: Date()) + if self.instantMessages[userID] == nil { + self.instantMessages[userID] = [instantMessage] + } + else { + self.instantMessages[userID]!.append(instantMessage) + } + self.unreadInstantMessages[userID] = userID + } } func hotlineReceivedChatMessage(message: String) { - if Prefs().playSounds && Prefs().playChatSound { + if Prefs.shared.playSounds && Prefs.shared.playChatSound { SoundEffectPlayer.shared.playSoundEffect(.chatMessage) } self.chat.append(ChatMessage(text: message, type: .message, date: Date())) } func hotlineReceivedUserList(users: [HotlineUser]) { - var existingUserIDs: [UInt] = [] + var existingUserIDs: [UInt16] = [] var userList: [User] = [] for u in users { @@ -701,7 +748,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { // If a user is already in the user list we have to assume // they changed somehow before we received the user list // which means let's keep their existing info. - existingUserIDs.append(UInt(u.id)) + existingUserIDs.append(u.id) userList.append(self.users[i]) } else { @@ -827,6 +874,7 @@ class Hotline: Equatable, HotlineClientDelegate, HotlineFileClientDelegate { } private func addOrUpdateHotlineUser(_ user: HotlineUser) { + print("Hotline: users: \n\(self.users)") if let i = self.users.firstIndex(where: { $0.id == user.id }) { print("Hotline: updating user \(self.users[i].name)") self.users[i] = User(hotlineUser: user) diff --git a/Hotline/Models/InstantMessage.swift b/Hotline/Models/InstantMessage.swift new file mode 100644 index 0000000..c41e33b --- /dev/null +++ b/Hotline/Models/InstantMessage.swift @@ -0,0 +1,18 @@ +import SwiftUI + +enum InstantMessageType { + case message +} + +enum InstantMessageDirection { + case incoming + case outgoing +} + +struct InstantMessage: Identifiable { + let id = UUID() + let direction: InstantMessageDirection + let text: String + let type: InstantMessageType + let date: Date +} diff --git a/Hotline/Models/User.swift b/Hotline/Models/User.swift index 7fbecb0..21a8fff 100644 --- a/Hotline/Models/User.swift +++ b/Hotline/Models/User.swift @@ -8,7 +8,7 @@ struct UserStatus: OptionSet { } struct User: Identifiable { - var id: UInt + var id: UInt16 var name: String var iconID: UInt var status: UserStatus @@ -21,13 +21,13 @@ struct User: Identifiable { if hotlineUser.isIdle { status.update(with: .idle) } if hotlineUser.isAdmin { status.update(with: .admin) } - self.id = UInt(hotlineUser.id) + self.id = hotlineUser.id self.name = hotlineUser.name self.iconID = UInt(hotlineUser.iconID) self.status = status } - init(id: UInt, name: String, iconID: UInt, status: UserStatus) { + init(id: UInt16, name: String, iconID: UInt, status: UserStatus) { self.id = id self.name = name self.iconID = iconID diff --git a/Hotline/Utility/FoundationExtensions.swift b/Hotline/Utility/FoundationExtensions.swift index 9ad0d8b..c154dbd 100644 --- a/Hotline/Utility/FoundationExtensions.swift +++ b/Hotline/Utility/FoundationExtensions.swift @@ -17,7 +17,6 @@ extension String { } regex.enumerateMatches(in: self, range: NSMakeRange(0, self.count)) { (result: NSTextCheckingResult!, _, _) in - let range = result.range if let newRange = Range(result.range, in: self) { let str = String(self[newRange]) attributedString.addAttribute(.link, value: str, range: result.range) diff --git a/Hotline/Utility/SoundEffects.swift b/Hotline/Utility/SoundEffects.swift index 931e27f..629af71 100644 --- a/Hotline/Utility/SoundEffects.swift +++ b/Hotline/Utility/SoundEffects.swift @@ -8,6 +8,7 @@ enum SoundEffects: String { case userLogin = "user-login" case userLogout = "user-logout" case newNews = "new-news" + case serverMessage = "server-message" } @Observable diff --git a/Hotline/macOS/ChatView.swift b/Hotline/macOS/ChatView.swift index 35ff28e..951544f 100644 --- a/Hotline/macOS/ChatView.swift +++ b/Hotline/macOS/ChatView.swift @@ -24,7 +24,6 @@ struct ChatView: View { var body: some View { NavigationStack { ScrollViewReader { reader in - VStack(alignment: .leading, spacing: 0) { // MARK: Scroll View @@ -193,17 +192,17 @@ struct ChatView: View { } } .background(Color(nsColor: .textBackgroundColor)) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - if prepareChatDocument() { - showingExporter = true - } - } label: { - Image(systemName: "square.and.arrow.up") - }.help("Save Chat...") - } - } +// .toolbar { +// ToolbarItem(placement: .primaryAction) { +// Button { +// if prepareChatDocument() { +// showingExporter = true +// } +// } label: { +// Image(systemName: "square.and.arrow.up") +// }.help("Save Chat...") +// } +// } .fileExporter(isPresented: $showingExporter, document: self.chatDocument, contentType: .utf8PlainText, defaultFilename: "\(self.model.serverTitle) Chat.txt") { result in switch result { case .success(let url): diff --git a/Hotline/macOS/MessageView.swift b/Hotline/macOS/MessageView.swift index e880435..94d3d2e 100644 --- a/Hotline/macOS/MessageView.swift +++ b/Hotline/macOS/MessageView.swift @@ -1,130 +1,108 @@ import SwiftUI -//extension View { -// func endEditing() { -// UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) -// } -//} - struct MessageView: View { @Environment(Hotline.self) private var model: Hotline - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme - @State var input: String = "" + @State private var input: String = "" @State private var scrollPos: Int? @State private var contentHeight: CGFloat = 0 + @Namespace private var bottomID + @FocusState private var focusedField: FocusedField? - @Namespace var bottomID - - let userID: UInt - + var userID: UInt16 + var body: some View { - NavigationStack { - VStack(spacing: 0) { - ScrollViewReader { reader in - ScrollView { + ScrollViewReader { reader in + VStack(alignment: .leading, spacing: 0) { + + // MARK: Scroll View + GeometryReader { gm in + ScrollView(.vertical) { LazyVStack(alignment: .leading) { -// ForEach(model.chat) { msg in -// if msg.type == .agreement { -// VStack(alignment: .leading) { -// VStack(alignment: .leading, spacing: 0) { -// Text(msg.text) -// .textSelection(.enabled) -// .padding() -// .opacity(0.75) -// HStack { -// Spacer() -// Text((model.serverTitle) + " Server Agreement") -// .font(.caption) -// .fontWeight(.medium) -// .opacity(0.4) -// .lineLimit(1) -// .truncationMode(.middle) -// Spacer() -// } -// .padding() -// .background(colorScheme == .dark ? Color(white: 0.2) : Color(white: 0.9)) -// } -// .background(colorScheme == .dark ? Color(white: 0.1) : Color(white: 0.96)) -// .cornerRadius(16) -// .frame(maxWidth: .infinity) -// } -// .padding() -// } -// else if msg.type == .status { -// HStack { -// Spacer() -// Text(msg.text) -// .lineLimit(1) -// .truncationMode(.middle) -// .opacity(0.3) -// Spacer() -// } -// .padding() -// } -// else { -// HStack(alignment: .firstTextBaseline) { -// if let username = msg.username { -// Text("**\(username):** \(msg.text)") -// } -// else { -// Text(msg.text) -// .textSelection(.enabled) -// } -// Spacer() -// } -// .padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) -// } -// } + ForEach(model.instantMessages[userID] ?? [InstantMessage]()) { msg in + HStack(alignment: .firstTextBaseline) { + if msg.direction == .outgoing { + Spacer() + } + + Text(LocalizedStringKey(msg.text.convertLinksToMarkdown())) + .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)) + } + EmptyView().id(bottomID) } - .padding(.bottom, 12) + .padding() } + .frame(maxWidth: .infinity, maxHeight: .infinity) .defaultScrollAnchor(.bottom) - .onChange(of: model.chat.count) { - withAnimation { + .onChange(of: model.instantMessages[userID]?.count) { + withAnimation(.easeOut(duration: 0.15).delay(0.25)) { reader.scrollTo(bottomID, anchor: .bottom) } - print("SCROLLED TO BOTTOM") + model.markInstantMessagesAsRead(userID: userID) } .onAppear { reader.scrollTo(bottomID, anchor: .bottom) + model.markInstantMessagesAsRead(userID: userID) } - .scrollDismissesKeyboard(.interactively) - .onTapGesture { -// self.endEditing() + .onChange(of: gm.size) { + reader.scrollTo(bottomID, anchor: .bottom) } - .textSelection(.enabled) } + // MARK: Input Divider Divider() - HStack(alignment: .top) { - Image(systemName: "chevron.right").opacity(0.4) - TextField("", text: $input, axis: .vertical) + // 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 { -// model.sendChat(self.input) -// } + if !self.input.isEmpty { + model.sendInstantMessage(self.input, userID: self.userID) + } self.input = "" } .frame(maxWidth: .infinity) - Button { -// if !self.input.isEmpty { -// model.sendChat(self.input) -// } - self.input = "" - } label: { - Image(systemName: self.input.isEmpty ? "arrow.up.circle" : "arrow.up.circle.fill") - .resizable() - .scaledToFit() - .frame(width: 24.0, height: 24.0) - .opacity(self.input.isEmpty ? 0.4 : 1.0) + .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 } - }.padding() + } + .onTapGesture { + focusedField = .chatInput + } } + .background(Color(nsColor: .textBackgroundColor)) } } } diff --git a/Hotline/macOS/ServerView.swift b/Hotline/macOS/ServerView.swift index c34fa68..b3ab302 100644 --- a/Hotline/macOS/ServerView.swift +++ b/Hotline/macOS/ServerView.swift @@ -115,7 +115,7 @@ enum ServerNavigationType: Identifiable, Hashable, Equatable { case news case board case files - case user(userID: UInt) + case user(userID: UInt16) } struct ServerView: View { @@ -351,6 +351,14 @@ struct ServerView: View { if model.users.count > 0 { self.usersSection + .onChange(of: state.selection) { + switch(state.selection) { + case .user(let userID): + model.markInstantMessagesAsRead(userID: userID) + default: + break + } + } } } } @@ -382,6 +390,13 @@ struct ServerView: View { .foregroundStyle(user.isAdmin ? Color(hex: 0xE10000) : .primary) Spacer() + + if model.hasUnreadInstantMessages(userID: user.id) { + Circle() + .frame(width: 6, height: 6) + .padding(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 2)) + .opacity(0.5) + } } .opacity(user.isIdle ? 0.6 : 1.0) .opacity(controlActiveState == .inactive ? 0.4 : 1.0) @@ -400,23 +415,32 @@ struct ServerView: View { case .chat: ChatView() .navigationTitle(model.serverTitle) + .navigationSubtitle("Public Chat") .navigationSplitViewColumnWidth(min: 250, ideal: 500) case .news: NewsView() .navigationTitle(model.serverTitle) + .navigationSubtitle("Newsgroup") .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 .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) + } } } } |