diff --git a/KDChat/KDChatApp.swift b/KDChat/KDChatApp.swift index 419feba..62c42ca 100644 --- a/KDChat/KDChatApp.swift +++ b/KDChat/KDChatApp.swift @@ -4,6 +4,10 @@ import SwiftUI struct KDChatApp: App { @State private var connectionManager = ConnectionManager() + init() { + SoundPlayer.configureSession() + } + var body: some Scene { WindowGroup { ContentView() diff --git a/KDChat/Models/ChatModels.swift b/KDChat/Models/ChatModels.swift index bed910e..5e4567a 100644 --- a/KDChat/Models/ChatModels.swift +++ b/KDChat/Models/ChatModels.swift @@ -47,8 +47,8 @@ struct ChatMessage: Identifiable, Sendable, Decodable { init(from decoder: Decoder) throws { var c = try decoder.unkeyedContainer() buffer = try c.decode(String.self) // [0] - let rawId: String? = try c.decodeNil() ? nil : c.decode(String.self) // [1] - id = rawId ?? UUID().uuidString + _ = try c.decodeNil() ? nil : c.decode(String.self) // [1] server msg ID (not unique) + id = UUID().uuidString senderId = try c.decodeNil() ? nil : c.decode(String.self) // [2] senderName = try c.decode(String.self) // [3] isInteractable = try c.decode(Bool.self) // [4] diff --git a/KDChat/Networking/LiteNetLib.swift b/KDChat/Networking/LiteNetLib.swift index 87936fa..b3457ca 100644 --- a/KDChat/Networking/LiteNetLib.swift +++ b/KDChat/Networking/LiteNetLib.swift @@ -276,7 +276,7 @@ actor LNLClient { switch state { case .connecting: - if now - lastConnectSendTime > 500 { + if now >= lastConnectSendTime, now - lastConnectSendTime > 500 { connectAttempts += 1 lnlLog.info("ConnectRequest attempt \(self.connectAttempts)/\(self.maxConnectAttempts)") if connectAttempts > maxConnectAttempts { @@ -288,7 +288,7 @@ actor LNLClient { } case .connected: - if now - lastPacketReceivedTime > UInt64(LNL.disconnectTimeoutMs) { + if now >= lastPacketReceivedTime, now - lastPacketReceivedTime > UInt64(LNL.disconnectTimeoutMs) { performDisconnect(reason: "Connection timed out") return } @@ -298,7 +298,7 @@ actor LNLClient { sendAck() mustSendAcks = false } - if now - lastPingSentTime > UInt64(LNL.pingIntervalMs) { + if now >= lastPingSentTime, now - lastPingSentTime > UInt64(LNL.pingIntervalMs) { sendPing() } @@ -522,7 +522,7 @@ actor LNLClient { while seq != localSequence { let idx = Int(seq) % LNL.windowSize if let pending = pendingPackets[idx] { - if Double(now - pending.timestamp) >= resendDelay { + if now >= pending.timestamp, Double(now - pending.timestamp) >= resendDelay { pendingPackets[idx]?.timestamp = now sendRaw(pending.data) } @@ -615,7 +615,9 @@ actor LNLClient { private func handlePong(_ data: Data) { guard data.count >= 3 else { return } - let elapsed = Double(currentMs() - pingSentTimestamp) + let now = currentMs() + guard now >= pingSentTimestamp else { return } + let elapsed = Double(now - pingSentTimestamp) rttSum += elapsed rttCount += 1 avgRtt = rttSum / Double(rttCount) diff --git a/KDChat/Networking/ProtocolTypes.swift b/KDChat/Networking/ProtocolTypes.swift index 63f93f8..ea37244 100644 --- a/KDChat/Networking/ProtocolTypes.swift +++ b/KDChat/Networking/ProtocolTypes.swift @@ -29,7 +29,8 @@ enum ClientMethod: Int32 { // 7 = TimeSync (unhandled) // 8 = Profile (unhandled) case say = 9 - // 10..32 = game-specific methods (unhandled) + case loadInstance = 10 + // 11..32 = game-specific methods (unhandled) case showRemoteForm = 33 case updateRemoteForm = 34 case closeRemoteForm = 35 diff --git a/KDChat/Services/ConnectionManager.swift b/KDChat/Services/ConnectionManager.swift index 6ac719a..077c885 100644 --- a/KDChat/Services/ConnectionManager.swift +++ b/KDChat/Services/ConnectionManager.swift @@ -2,6 +2,12 @@ import Foundation import SwiftUI import os +private extension UserDefaults { + func bool(forKey key: String, default defaultValue: Bool) -> Bool { + object(forKey: key) == nil ? defaultValue : bool(forKey: key) + } +} + private let log = Logger(subsystem: "dev.smoll.KDChat", category: "Connection") // MARK: - App Screen @@ -38,10 +44,14 @@ final class ConnectionManager { var buffers: [ChatBuffer] = [] var messages: [String: [ChatMessage]] = [:] var currentCharacterId: String? + var localSenderId: String? // Feedback triggers (views observe these for sensoryFeedback / announcements) var messageFeedbackTrigger: Int = 0 var systemFeedbackTrigger: Int = 0 + + // Tracks which buffer the user is viewing (set by ChatView) + var activeBuffer: String = "global" var lastAnnouncement: String = "" // Auth @@ -239,6 +249,12 @@ final class ConnectionManager { case .addMessage: guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return } handleAddMessage(message) + case .loadInstance: + if let value = PacketDecoder.decodeDynamic(from: payload), + let entityId = value[1]?.stringValue { + localSenderId = entityId + log.info("Local entity ID: \(entityId)") + } case .requestGameplay: handleRequestGameplay() case .leaveGameUpdate: @@ -329,6 +345,7 @@ final class ConnectionManager { messageFeedbackTrigger += 1 lastAnnouncement = "\(message.senderName): \(message.content)" + playMessageSound(buffer: message.buffer) } private func handleSay(_ say: ServerSay) { @@ -342,6 +359,29 @@ final class ConnectionManager { lastAnnouncement = say.message } + private func playMessageSound(buffer: String) { + guard UserDefaults.standard.bool(forKey: "sounds_enabled", default: true) else { return } + + let isActiveBuffer = buffer == activeBuffer + if !isActiveBuffer { + guard UserDefaults.standard.bool(forKey: "sounds_background_buffers", default: true) else { return } + } + + switch buffer { + case "global": + guard UserDefaults.standard.bool(forKey: "sounds_global", default: true) else { return } + SoundPlayer.play("global") + case "map": + guard UserDefaults.standard.bool(forKey: "sounds_map", default: true) else { return } + SoundPlayer.play("map") + case "direct-messages": + guard UserDefaults.standard.bool(forKey: "sounds_dm", default: true) else { return } + SoundPlayer.play("direct-message") + default: + break + } + } + // MARK: - Game State Handlers private func handleRequestGameplay() { diff --git a/KDChat/Services/SoundPlayer.swift b/KDChat/Services/SoundPlayer.swift new file mode 100644 index 0000000..9291ce8 --- /dev/null +++ b/KDChat/Services/SoundPlayer.swift @@ -0,0 +1,36 @@ +import AVFoundation +import os + +private let soundLog = Logger(subsystem: "dev.smoll.KDChat", category: "Sound") + +enum SoundPlayer { + private static var players: [String: AVAudioPlayer] = [:] + + static func play(_ name: String) { + if let cached = players[name] { + cached.currentTime = 0 + cached.play() + return + } + guard let url = Bundle.main.url(forResource: name, withExtension: "caf") else { + soundLog.warning("Sound not found: \(name).caf") + return + } + do { + let player = try AVAudioPlayer(contentsOf: url) + player.prepareToPlay() + player.play() + players[name] = player + } catch { + soundLog.error("Failed to play \(name): \(error.localizedDescription)") + } + } + + static func configureSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) + } catch { + soundLog.error("Failed to configure audio session: \(error.localizedDescription)") + } + } +} diff --git a/KDChat/Sounds/direct-message.caf b/KDChat/Sounds/direct-message.caf new file mode 100644 index 0000000..464be11 Binary files /dev/null and b/KDChat/Sounds/direct-message.caf differ diff --git a/KDChat/Sounds/global.caf b/KDChat/Sounds/global.caf new file mode 100644 index 0000000..29d49e0 Binary files /dev/null and b/KDChat/Sounds/global.caf differ diff --git a/KDChat/Sounds/map.caf b/KDChat/Sounds/map.caf new file mode 100644 index 0000000..a6faec2 Binary files /dev/null and b/KDChat/Sounds/map.caf differ diff --git a/KDChat/Views/ChatBufferView.swift b/KDChat/Views/ChatBufferView.swift index 3467625..6b7e04e 100644 --- a/KDChat/Views/ChatBufferView.swift +++ b/KDChat/Views/ChatBufferView.swift @@ -15,13 +15,11 @@ struct ChatBufferView: View { VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { + LazyVStack(alignment: .leading, spacing: 12) { ForEach(messages) { message in MessageRow( message: message, - onTapSender: message.isInteractable ? { startDM(to: message) } : nil, - onDM: onDM, - onProfile: nil // TODO: profile viewing + onDM: onDM ) .id(message.id) } @@ -77,8 +75,4 @@ struct ChatBufferView: View { messageText = "" } - private func startDM(to message: ChatMessage) { - guard message.senderId != nil else { return } - // Full DM flow would use sendDirectMessage with a selected recipient - } } diff --git a/KDChat/Views/ChatView.swift b/KDChat/Views/ChatView.swift index e591f0c..8ca1c74 100644 --- a/KDChat/Views/ChatView.swift +++ b/KDChat/Views/ChatView.swift @@ -55,6 +55,9 @@ struct ChatView: View { .onChange(of: manager.lastAnnouncement) { AccessibilityNotification.Announcement(manager.lastAnnouncement).post() } + .onChange(of: selectedBuffer) { + manager.activeBuffer = selectedBuffer + } } } diff --git a/KDChat/Views/MessageRow.swift b/KDChat/Views/MessageRow.swift index a5015b6..4856110 100644 --- a/KDChat/Views/MessageRow.swift +++ b/KDChat/Views/MessageRow.swift @@ -2,59 +2,67 @@ import SwiftUI struct MessageRow: View { let message: ChatMessage - var onTapSender: (() -> Void)? var onDM: ((ChatMessage) -> Void)? var onProfile: ((ChatMessage) -> Void)? + @Environment(ConnectionManager.self) private var manager + + private var isOwnMessage: Bool { + message.senderIsRecipient || message.senderId == manager.localSenderId + } var body: some View { if message.isSystem { Text(message.content) - .font(.subheadline) + .font(.body) .foregroundStyle(.secondary) .italic() .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 4) + .padding(.vertical, 6) } else { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 4) { - if let tap = onTapSender { - Button(message.senderName, action: tap) - .font(.subheadline).bold() - .accessibilityHint("Tap to send a direct message") - } else { - Text(message.senderName) - .font(.subheadline).bold() - .foregroundStyle(Color.accentColor) - } + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(message.senderName) + .font(.headline) + .foregroundStyle(Color.accentColor) if message.senderIsRecipient { Text("(you)") - .font(.caption) + .font(.subheadline) .foregroundStyle(.secondary) } Spacer() Text(message.timestamp, style: .time) - .font(.caption) + .font(.subheadline) .foregroundStyle(.secondary) } Text(message.content) - .font(.body) + .font(.title3) } - .padding(.horizontal, 10) - .padding(.vertical, 8) + .padding(.horizontal, 12) + .padding(.vertical, 10) .background(.regularMaterial) .clipShape(.rect(cornerRadius: 12)) + .contextMenu { + if !isOwnMessage, message.senderId != nil { + if let onDM { + Button("DM \(message.senderName)", systemImage: "envelope") { onDM(message) } + } + if let onProfile { + Button("Show \(message.senderName)'s Profile", systemImage: "person.circle") { onProfile(message) } + } + } + } .accessibilityElement(children: .combine) .accessibilityActions { - if !message.senderIsRecipient, message.senderId != nil { + if !isOwnMessage, message.senderId != nil { if let onDM { Button("DM \(message.senderName)") { onDM(message) } } if let onProfile { - Button("Profile for \(message.senderName)") { onProfile(message) } + Button("Show \(message.senderName)'s Profile") { onProfile(message) } } } } diff --git a/KDChat/Views/SettingsView.swift b/KDChat/Views/SettingsView.swift index 195a142..dfc9fbc 100644 --- a/KDChat/Views/SettingsView.swift +++ b/KDChat/Views/SettingsView.swift @@ -4,9 +4,26 @@ struct SettingsView: View { @AppStorage("haptics_enabled") private var hapticsEnabled = true @AppStorage("haptics_messages") private var messageHaptics = true @AppStorage("haptics_status") private var statusHaptics = true + @AppStorage("sounds_enabled") private var soundsEnabled = true + @AppStorage("sounds_global") private var globalSound = true + @AppStorage("sounds_map") private var mapSound = true + @AppStorage("sounds_dm") private var dmSound = true + @AppStorage("sounds_background_buffers") private var backgroundBufferSounds = true var body: some View { Form { + Section("Sounds") { + Toggle("Sounds", isOn: $soundsEnabled) + Toggle("Global Chat", isOn: $globalSound) + .disabled(!soundsEnabled) + Toggle("Map Chat", isOn: $mapSound) + .disabled(!soundsEnabled) + Toggle("Direct Messages", isOn: $dmSound) + .disabled(!soundsEnabled) + Toggle("Other Buffers", isOn: $backgroundBufferSounds) + .disabled(!soundsEnabled) + } + Section("Haptics") { Toggle("Haptics", isOn: $hapticsEnabled) Toggle("Message Haptics", isOn: $messageHaptics)