Chat sounds, message card UI, own-message detection, and crash fix

Sounds:
- Add chat sounds (global, map, DM) converted from game client ogg to caf
- Audio category .ambient (mixes with other audio, respects silent switch)
- Per-buffer sound toggles and background buffer sound option in Settings

Messages:
- Card-style message rows with .regularMaterial background, bumped fonts (content .title3, sender .headline)
- Context menu and VoiceOver actions: DM and Show Profile on other players' messages
- Generate local UUIDs for message identity (server ID field is not unique, caused ForEach to collapse messages)

Own-message detection:
- Capture player entity ID from LoadInstance packet (Key 1) on game join
- Hide DM/Profile actions on own messages via senderId comparison

Crash fix:
- Guard all UInt64 timestamp subtractions against underflow (retransmitPending, ping, disconnect timeout)
This commit is contained in:
Blake Oliver 2026-04-05 08:11:36 -06:00
parent 2d3fefa013
commit f9c751a993
No known key found for this signature in database
13 changed files with 142 additions and 37 deletions

View file

@ -4,6 +4,10 @@ import SwiftUI
struct KDChatApp: App { struct KDChatApp: App {
@State private var connectionManager = ConnectionManager() @State private var connectionManager = ConnectionManager()
init() {
SoundPlayer.configureSession()
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()

View file

@ -47,8 +47,8 @@ struct ChatMessage: Identifiable, Sendable, Decodable {
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
var c = try decoder.unkeyedContainer() var c = try decoder.unkeyedContainer()
buffer = try c.decode(String.self) // [0] buffer = try c.decode(String.self) // [0]
let rawId: String? = try c.decodeNil() ? nil : c.decode(String.self) // [1] _ = try c.decodeNil() ? nil : c.decode(String.self) // [1] server msg ID (not unique)
id = rawId ?? UUID().uuidString id = UUID().uuidString
senderId = try c.decodeNil() ? nil : c.decode(String.self) // [2] senderId = try c.decodeNil() ? nil : c.decode(String.self) // [2]
senderName = try c.decode(String.self) // [3] senderName = try c.decode(String.self) // [3]
isInteractable = try c.decode(Bool.self) // [4] isInteractable = try c.decode(Bool.self) // [4]

View file

@ -276,7 +276,7 @@ actor LNLClient {
switch state { switch state {
case .connecting: case .connecting:
if now - lastConnectSendTime > 500 { if now >= lastConnectSendTime, now - lastConnectSendTime > 500 {
connectAttempts += 1 connectAttempts += 1
lnlLog.info("ConnectRequest attempt \(self.connectAttempts)/\(self.maxConnectAttempts)") lnlLog.info("ConnectRequest attempt \(self.connectAttempts)/\(self.maxConnectAttempts)")
if connectAttempts > maxConnectAttempts { if connectAttempts > maxConnectAttempts {
@ -288,7 +288,7 @@ actor LNLClient {
} }
case .connected: case .connected:
if now - lastPacketReceivedTime > UInt64(LNL.disconnectTimeoutMs) { if now >= lastPacketReceivedTime, now - lastPacketReceivedTime > UInt64(LNL.disconnectTimeoutMs) {
performDisconnect(reason: "Connection timed out") performDisconnect(reason: "Connection timed out")
return return
} }
@ -298,7 +298,7 @@ actor LNLClient {
sendAck() sendAck()
mustSendAcks = false mustSendAcks = false
} }
if now - lastPingSentTime > UInt64(LNL.pingIntervalMs) { if now >= lastPingSentTime, now - lastPingSentTime > UInt64(LNL.pingIntervalMs) {
sendPing() sendPing()
} }
@ -522,7 +522,7 @@ actor LNLClient {
while seq != localSequence { while seq != localSequence {
let idx = Int(seq) % LNL.windowSize let idx = Int(seq) % LNL.windowSize
if let pending = pendingPackets[idx] { if let pending = pendingPackets[idx] {
if Double(now - pending.timestamp) >= resendDelay { if now >= pending.timestamp, Double(now - pending.timestamp) >= resendDelay {
pendingPackets[idx]?.timestamp = now pendingPackets[idx]?.timestamp = now
sendRaw(pending.data) sendRaw(pending.data)
} }
@ -615,7 +615,9 @@ actor LNLClient {
private func handlePong(_ data: Data) { private func handlePong(_ data: Data) {
guard data.count >= 3 else { return } 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 rttSum += elapsed
rttCount += 1 rttCount += 1
avgRtt = rttSum / Double(rttCount) avgRtt = rttSum / Double(rttCount)

View file

@ -29,7 +29,8 @@ enum ClientMethod: Int32 {
// 7 = TimeSync (unhandled) // 7 = TimeSync (unhandled)
// 8 = Profile (unhandled) // 8 = Profile (unhandled)
case say = 9 case say = 9
// 10..32 = game-specific methods (unhandled) case loadInstance = 10
// 11..32 = game-specific methods (unhandled)
case showRemoteForm = 33 case showRemoteForm = 33
case updateRemoteForm = 34 case updateRemoteForm = 34
case closeRemoteForm = 35 case closeRemoteForm = 35

View file

@ -2,6 +2,12 @@ import Foundation
import SwiftUI import SwiftUI
import os 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") private let log = Logger(subsystem: "dev.smoll.KDChat", category: "Connection")
// MARK: - App Screen // MARK: - App Screen
@ -38,10 +44,14 @@ final class ConnectionManager {
var buffers: [ChatBuffer] = [] var buffers: [ChatBuffer] = []
var messages: [String: [ChatMessage]] = [:] var messages: [String: [ChatMessage]] = [:]
var currentCharacterId: String? var currentCharacterId: String?
var localSenderId: String?
// Feedback triggers (views observe these for sensoryFeedback / announcements) // Feedback triggers (views observe these for sensoryFeedback / announcements)
var messageFeedbackTrigger: Int = 0 var messageFeedbackTrigger: Int = 0
var systemFeedbackTrigger: Int = 0 var systemFeedbackTrigger: Int = 0
// Tracks which buffer the user is viewing (set by ChatView)
var activeBuffer: String = "global"
var lastAnnouncement: String = "" var lastAnnouncement: String = ""
// Auth // Auth
@ -239,6 +249,12 @@ final class ConnectionManager {
case .addMessage: case .addMessage:
guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return } guard let message = PacketDecoder.decode(ChatMessage.self, from: payload) else { return }
handleAddMessage(message) 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: case .requestGameplay:
handleRequestGameplay() handleRequestGameplay()
case .leaveGameUpdate: case .leaveGameUpdate:
@ -329,6 +345,7 @@ final class ConnectionManager {
messageFeedbackTrigger += 1 messageFeedbackTrigger += 1
lastAnnouncement = "\(message.senderName): \(message.content)" lastAnnouncement = "\(message.senderName): \(message.content)"
playMessageSound(buffer: message.buffer)
} }
private func handleSay(_ say: ServerSay) { private func handleSay(_ say: ServerSay) {
@ -342,6 +359,29 @@ final class ConnectionManager {
lastAnnouncement = say.message 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 // MARK: - Game State Handlers
private func handleRequestGameplay() { private func handleRequestGameplay() {

View file

@ -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)")
}
}
}

Binary file not shown.

BIN
KDChat/Sounds/global.caf Normal file

Binary file not shown.

BIN
KDChat/Sounds/map.caf Normal file

Binary file not shown.

View file

@ -15,13 +15,11 @@ struct ChatBufferView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 8) { LazyVStack(alignment: .leading, spacing: 12) {
ForEach(messages) { message in ForEach(messages) { message in
MessageRow( MessageRow(
message: message, message: message,
onTapSender: message.isInteractable ? { startDM(to: message) } : nil, onDM: onDM
onDM: onDM,
onProfile: nil // TODO: profile viewing
) )
.id(message.id) .id(message.id)
} }
@ -77,8 +75,4 @@ struct ChatBufferView: View {
messageText = "" messageText = ""
} }
private func startDM(to message: ChatMessage) {
guard message.senderId != nil else { return }
// Full DM flow would use sendDirectMessage with a selected recipient
}
} }

View file

@ -55,6 +55,9 @@ struct ChatView: View {
.onChange(of: manager.lastAnnouncement) { .onChange(of: manager.lastAnnouncement) {
AccessibilityNotification.Announcement(manager.lastAnnouncement).post() AccessibilityNotification.Announcement(manager.lastAnnouncement).post()
} }
.onChange(of: selectedBuffer) {
manager.activeBuffer = selectedBuffer
}
} }
} }

View file

@ -2,59 +2,67 @@ import SwiftUI
struct MessageRow: View { struct MessageRow: View {
let message: ChatMessage let message: ChatMessage
var onTapSender: (() -> Void)?
var onDM: ((ChatMessage) -> Void)? var onDM: ((ChatMessage) -> Void)?
var onProfile: ((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 { var body: some View {
if message.isSystem { if message.isSystem {
Text(message.content) Text(message.content)
.font(.subheadline) .font(.body)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.italic() .italic()
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 4) .padding(.vertical, 6)
} else { } else {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 4) { HStack {
if let tap = onTapSender { Text(message.senderName)
Button(message.senderName, action: tap) .font(.headline)
.font(.subheadline).bold() .foregroundStyle(Color.accentColor)
.accessibilityHint("Tap to send a direct message")
} else {
Text(message.senderName)
.font(.subheadline).bold()
.foregroundStyle(Color.accentColor)
}
if message.senderIsRecipient { if message.senderIsRecipient {
Text("(you)") Text("(you)")
.font(.caption) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(message.timestamp, style: .time) Text(message.timestamp, style: .time)
.font(.caption) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text(message.content) Text(message.content)
.font(.body) .font(.title3)
} }
.padding(.horizontal, 10) .padding(.horizontal, 12)
.padding(.vertical, 8) .padding(.vertical, 10)
.background(.regularMaterial) .background(.regularMaterial)
.clipShape(.rect(cornerRadius: 12)) .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) .accessibilityElement(children: .combine)
.accessibilityActions { .accessibilityActions {
if !message.senderIsRecipient, message.senderId != nil { if !isOwnMessage, message.senderId != nil {
if let onDM { if let onDM {
Button("DM \(message.senderName)") { onDM(message) } Button("DM \(message.senderName)") { onDM(message) }
} }
if let onProfile { if let onProfile {
Button("Profile for \(message.senderName)") { onProfile(message) } Button("Show \(message.senderName)'s Profile") { onProfile(message) }
} }
} }
} }

View file

@ -4,9 +4,26 @@ struct SettingsView: View {
@AppStorage("haptics_enabled") private var hapticsEnabled = true @AppStorage("haptics_enabled") private var hapticsEnabled = true
@AppStorage("haptics_messages") private var messageHaptics = true @AppStorage("haptics_messages") private var messageHaptics = true
@AppStorage("haptics_status") private var statusHaptics = 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 { var body: some View {
Form { 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") { Section("Haptics") {
Toggle("Haptics", isOn: $hapticsEnabled) Toggle("Haptics", isOn: $hapticsEnabled)
Toggle("Message Haptics", isOn: $messageHaptics) Toggle("Message Haptics", isOn: $messageHaptics)