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:
parent
2d3fefa013
commit
f9c751a993
13 changed files with 142 additions and 37 deletions
|
|
@ -4,6 +4,10 @@ import SwiftUI
|
|||
struct KDChatApp: App {
|
||||
@State private var connectionManager = ConnectionManager()
|
||||
|
||||
init() {
|
||||
SoundPlayer.configureSession()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
36
KDChat/Services/SoundPlayer.swift
Normal file
36
KDChat/Services/SoundPlayer.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
KDChat/Sounds/direct-message.caf
Normal file
BIN
KDChat/Sounds/direct-message.caf
Normal file
Binary file not shown.
BIN
KDChat/Sounds/global.caf
Normal file
BIN
KDChat/Sounds/global.caf
Normal file
Binary file not shown.
BIN
KDChat/Sounds/map.caf
Normal file
BIN
KDChat/Sounds/map.caf
Normal file
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ struct ChatView: View {
|
|||
.onChange(of: manager.lastAnnouncement) {
|
||||
AccessibilityNotification.Announcement(manager.lastAnnouncement).post()
|
||||
}
|
||||
.onChange(of: selectedBuffer) {
|
||||
manager.activeBuffer = selectedBuffer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue