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 {
@State private var connectionManager = ConnectionManager()
init() {
SoundPlayer.configureSession()
}
var body: some Scene {
WindowGroup {
ContentView()

View file

@ -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]

View file

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

View file

@ -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

View file

@ -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() {

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) {
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
}
}

View file

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

View file

@ -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)
} 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")
.padding(.vertical, 6)
} else {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(message.senderName)
.font(.subheadline).bold()
.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) }
}
}
}

View file

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