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 {
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
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) {
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
|
||||||
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 {
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
Text(message.senderName)
|
Text(message.senderName)
|
||||||
.font(.subheadline).bold()
|
.font(.headline)
|
||||||
.foregroundStyle(Color.accentColor)
|
.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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue