From 8a39e5939fa4f078fafab538ffba4db02dfed5d8 Mon Sep 17 00:00:00 2001 From: Blake Oliver Date: Sun, 5 Apr 2026 18:46:38 -0600 Subject: [PATCH] Add swiftData message persistence and reactive side effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistence: - SavedMessage SwiftData model with index on [buffer, timestamp] - @Query in ChatBufferView/DMListView/DMConversationView — SwiftData is the single source of truth, no in-memory dictionary - Settings: Save Messages toggle, Save Debug Buffer toggle, Delete After picker (1d/1w/1m/1y/never), Delete All with confirmation dialog - Expired and debug messages purged on launch based on settings - Say messages go to debug buffer only (not all buffers) Reactive side effects: - Sound, haptic, and VoiceOver announcement all fire from ChatBufferView's onChange(of: messages.count) - Sound plays for active buffer or background buffers if enabled - Haptic and announcement fire on active buffer only, current session only (I'll add a toggle to announce other buffers next) - Status haptic for Say messages stays imperative in model (global event, not per-buffer) - Removed trigger counters and lastAnnouncement from ConnectionManager DM conversation fix: - Track DM recipient via lastDMRecipientId; tag outgoing echoes with partner ID in characterId - Conversation view filters on characterId == partnerId (shows only that conversation) Other: - modelContext set in ContentView.onAppear (available for all screens) - Instant scroll (no animation), respects Reduce Motion --- KDChat/ContentView.swift | 40 +++++++++++ KDChat/KDChatApp.swift | 2 + KDChat/Models/ChatModels.swift | 4 +- KDChat/Models/SavedMessage.swift | 38 +++++++++++ KDChat/Services/ConnectionManager.swift | 90 +++++++++++++------------ KDChat/Views/ChatBufferView.swift | 59 ++++++++++++++-- KDChat/Views/ChatView.swift | 20 +++--- KDChat/Views/DMConversationView.swift | 25 ++++--- KDChat/Views/DMListView.swift | 18 ++--- KDChat/Views/MessageRow.swift | 6 +- KDChat/Views/SettingsView.swift | 67 ++++++++++++++++++ 11 files changed, 287 insertions(+), 82 deletions(-) create mode 100644 KDChat/Models/SavedMessage.swift diff --git a/KDChat/ContentView.swift b/KDChat/ContentView.swift index 82f1337..18a98ac 100644 --- a/KDChat/ContentView.swift +++ b/KDChat/ContentView.swift @@ -1,7 +1,9 @@ +import SwiftData import SwiftUI struct ContentView: View { @Environment(ConnectionManager.self) private var manager + @Environment(\.modelContext) private var modelContext var body: some View { Group { @@ -17,5 +19,43 @@ struct ContentView: View { } } .animation(.default, value: manager.screen) + .onAppear { + manager.modelContext = modelContext + purgeExpiredMessages() + } + } + + private func purgeExpiredMessages() { + // Purge all if save is off + if !UserDefaults.standard.bool(forKey: "save_messages", default: true) { + try? modelContext.delete(model: SavedMessage.self) + try? modelContext.save() + return + } + + // Purge expired messages + let deleteAfterRaw = UserDefaults.standard.integer(forKey: "delete_after") + let days = deleteAfterRaw == 0 ? 0 : (DeleteAfter(rawValue: deleteAfterRaw) ?? .oneMonth).rawValue + if days > 0 { + let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: .now) ?? .now + let expired = FetchDescriptor( + predicate: #Predicate { $0.timestamp < cutoff } + ) + if let old = try? modelContext.fetch(expired) { + for message in old { modelContext.delete(message) } + } + } + + // Purge debug messages if not saving them + if !UserDefaults.standard.bool(forKey: "save_debug_buffer") { + let debugDesc = FetchDescriptor( + predicate: #Predicate { $0.buffer == "debug" } + ) + if let debugMsgs = try? modelContext.fetch(debugDesc) { + for message in debugMsgs { modelContext.delete(message) } + } + } + + try? modelContext.save() } } diff --git a/KDChat/KDChatApp.swift b/KDChat/KDChatApp.swift index 62c42ca..8b0174f 100644 --- a/KDChat/KDChatApp.swift +++ b/KDChat/KDChatApp.swift @@ -1,3 +1,4 @@ +import SwiftData import SwiftUI @main @@ -13,5 +14,6 @@ struct KDChatApp: App { ContentView() .environment(connectionManager) } + .modelContainer(for: SavedMessage.self) } } diff --git a/KDChat/Models/ChatModels.swift b/KDChat/Models/ChatModels.swift index 5e4567a..9dcbe95 100644 --- a/KDChat/Models/ChatModels.swift +++ b/KDChat/Models/ChatModels.swift @@ -56,7 +56,7 @@ struct ChatMessage: Identifiable, Sendable, Decodable { _ = c.isAtEnd ? nil : try (c.decodeNil() ? nil : c.decode(String.self)) // [6] sound senderIsRecipient = c.isAtEnd ? false : try c.decode(Bool.self) // [7] isSystem = false - timestamp = Date() + timestamp = .now } /// Create a system message (from Say packets) @@ -69,6 +69,6 @@ struct ChatMessage: Identifiable, Sendable, Decodable { self.isInteractable = false self.senderIsRecipient = false self.isSystem = true - self.timestamp = Date() + self.timestamp = .now } } diff --git a/KDChat/Models/SavedMessage.swift b/KDChat/Models/SavedMessage.swift new file mode 100644 index 0000000..8a7de0f --- /dev/null +++ b/KDChat/Models/SavedMessage.swift @@ -0,0 +1,38 @@ +import Foundation +import SwiftData + +@Model class SavedMessage { + #Index([\.buffer, \.timestamp]) + + var buffer: String + var senderId: String? + var senderName: String + var content: String + var timestamp: Date + var isSystem: Bool = false + var senderIsRecipient: Bool = false + var characterId: String? + var sessionId: UUID + + init( + buffer: String, + senderId: String?, + senderName: String, + content: String, + timestamp: Date = .now, + isSystem: Bool = false, + senderIsRecipient: Bool = false, + characterId: String? = nil, + sessionId: UUID + ) { + self.buffer = buffer + self.senderId = senderId + self.senderName = senderName + self.content = content + self.timestamp = timestamp + self.isSystem = isSystem + self.senderIsRecipient = senderIsRecipient + self.characterId = characterId + self.sessionId = sessionId + } +} diff --git a/KDChat/Services/ConnectionManager.swift b/KDChat/Services/ConnectionManager.swift index 94a6a97..9bbde9d 100644 --- a/KDChat/Services/ConnectionManager.swift +++ b/KDChat/Services/ConnectionManager.swift @@ -1,9 +1,10 @@ import Foundation import MessagePack +import SwiftData import SwiftUI import os -private extension UserDefaults { +extension UserDefaults { func bool(forKey key: String, default defaultValue: Bool) -> Bool { object(forKey: key) == nil ? defaultValue : bool(forKey: key) } @@ -43,17 +44,14 @@ final class ConnectionManager { // Chat 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 + var sessionId = UUID() + var modelContext: ModelContext? + private var lastDMRecipientId: String? // Tracks which buffer the user is viewing (set by ChatView) var activeBuffer: String = "global" - var lastAnnouncement: String = "" // Auth private(set) var isLoggedIn: Bool = false @@ -105,6 +103,7 @@ final class ConnectionManager { private func startClient(host: String, port: UInt16) { tearDownClient() + sessionId = UUID() let client = LNLClient() self.client = client @@ -138,6 +137,7 @@ final class ConnectionManager { } func sendDirectMessage(recipientId: String, message: String) { + lastDMRecipientId = recipientId let packet = PacketEncoder.directMessage(recipientId: recipientId, message: message) Task { await client?.send(data: packet) } } @@ -213,9 +213,9 @@ final class ConnectionManager { private func resetState() { buffers.removeAll() - messages.removeAll() remoteForms.removeAll() currentCharacterId = nil + localSenderId = nil errorMessage = nil } @@ -343,58 +343,64 @@ final class ConnectionManager { log.info("addBuffer: \(buffer.name), canSend=\(buffer.canSend)") if !buffers.contains(where: { $0.name == buffer.name }) { buffers.append(buffer) - messages[buffer.name] = [] } } private func handleAddMessage(_ message: ChatMessage) { - messages[message.buffer, default: []].append(message) - // Learn our own entity ID from echoed messages if message.senderIsRecipient, let id = message.senderId { if localSenderId == nil { localSenderId = id } if currentCharacterId == nil { currentCharacterId = id } } - messageFeedbackTrigger += 1 - lastAnnouncement = "\(message.senderName): \(message.content)" - playMessageSound(buffer: message.buffer) + // For DMs, characterId stores the conversation partner's ID + let dmPartnerId: String? = if message.buffer == "direct-messages" { + message.senderIsRecipient ? lastDMRecipientId : message.senderId + } else { + nil + } + + let saved = SavedMessage( + buffer: message.buffer, + senderId: message.senderId, + senderName: message.senderName, + content: message.content, + senderIsRecipient: message.senderIsRecipient, + characterId: dmPartnerId, + sessionId: sessionId + ) + modelContext?.insert(saved) + try? modelContext?.save() + } private func handleSay(_ say: ServerSay) { - let sysMessage = ChatMessage(systemMessage: say.message) + let saved = SavedMessage( + buffer: "debug", + senderId: nil, + senderName: "System", + content: say.message, + isSystem: true, + sessionId: sessionId + ) + modelContext?.insert(saved) + try? modelContext?.save() - for buffer in buffers { - messages[buffer.name, default: []].append(sysMessage) - } - - systemFeedbackTrigger += 1 - lastAnnouncement = say.message + announce(say.message) + playStatusHaptic() } - 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 - } + private func announce(_ text: String) { + AccessibilityNotification.Announcement(text).post() } + private func playStatusHaptic() { + guard UserDefaults.standard.bool(forKey: "haptics_enabled", default: true), + UserDefaults.standard.bool(forKey: "haptics_status", default: true) else { return } + UINotificationFeedbackGenerator().notificationOccurred(.warning) + } + + // MARK: - Game State Handlers diff --git a/KDChat/Views/ChatBufferView.swift b/KDChat/Views/ChatBufferView.swift index 6b7e04e..75fa71d 100644 --- a/KDChat/Views/ChatBufferView.swift +++ b/KDChat/Views/ChatBufferView.swift @@ -1,14 +1,25 @@ +import SwiftData import SwiftUI struct ChatBufferView: View { let bufferName: String let canSend: Bool - var onDM: ((ChatMessage) -> Void)? + var onDM: ((SavedMessage) -> Void)? @Environment(ConnectionManager.self) private var manager + @Environment(\.modelContext) private var modelContext + @Query private var messages: [SavedMessage] @State private var messageText = "" - private var messages: [ChatMessage] { - manager.messages[bufferName] ?? [] + init(bufferName: String, canSend: Bool, onDM: ((SavedMessage) -> Void)? = nil) { + self.bufferName = bufferName + self.canSend = canSend + self.onDM = onDM + + let buf = bufferName + _messages = Query( + filter: #Predicate { $0.buffer == buf }, + sort: \.timestamp + ) } var body: some View { @@ -21,15 +32,33 @@ struct ChatBufferView: View { message: message, onDM: onDM ) - .id(message.id) + .id(message.persistentModelID) } } .padding() } .onChange(of: messages.count) { - if let lastId = messages.last?.id { - withAnimation(.easeOut(duration: 0.2)) { - proxy.scrollTo(lastId, anchor: .bottom) + guard let last = messages.last, last.sessionId == manager.sessionId else { return } + proxy.scrollTo(last.persistentModelID, anchor: .bottom) + + let isActive = bufferName == manager.activeBuffer + let playBackground = UserDefaults.standard.bool(forKey: "sounds_background_buffers", default: true) + + // Sound — plays for active buffer, or background if enabled + if isActive || playBackground { + playBufferSound() + } + + // Announce + haptic — active buffer only + if isActive { + if !last.isSystem { + AccessibilityNotification.Announcement( + "\(last.senderName): \(last.content)" + ).post() + } + if UserDefaults.standard.bool(forKey: "haptics_enabled", default: true), + UserDefaults.standard.bool(forKey: "haptics_messages", default: true) { + UIImpactFeedbackGenerator(style: .soft).impactOccurred() } } } @@ -75,4 +104,20 @@ struct ChatBufferView: View { messageText = "" } + private func playBufferSound() { + guard UserDefaults.standard.bool(forKey: "sounds_enabled", default: true) else { return } + switch bufferName { + 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 + } + } } diff --git a/KDChat/Views/ChatView.swift b/KDChat/Views/ChatView.swift index db2dd45..32e7bae 100644 --- a/KDChat/Views/ChatView.swift +++ b/KDChat/Views/ChatView.swift @@ -1,3 +1,4 @@ +import SwiftData import SwiftUI struct ChatView: View { @@ -5,9 +6,6 @@ struct ChatView: View { @State private var selectedBuffer: String = "global" @State private var showSettings = false @State private var selectedDMPartner: String? - @AppStorage("haptics_enabled") private var hapticsEnabled = true - @AppStorage("haptics_messages") private var messageHaptics = true - @AppStorage("haptics_status") private var statusHaptics = true var body: some View { NavigationStack { @@ -50,11 +48,6 @@ struct ChatView: View { let name = partnerName(for: partnerId) DMConversationView(partnerId: partnerId, partnerName: name) } - .sensoryFeedback(.impact(flexibility: .soft), trigger: hapticsEnabled && messageHaptics ? manager.messageFeedbackTrigger : 0) - .sensoryFeedback(.warning, trigger: hapticsEnabled && statusHaptics ? manager.systemFeedbackTrigger : 0) - .onChange(of: manager.lastAnnouncement) { - AccessibilityNotification.Announcement(manager.lastAnnouncement).post() - } .onChange(of: selectedBuffer) { manager.activeBuffer = selectedBuffer } @@ -86,12 +79,17 @@ struct ChatView: View { } } + @Environment(\.modelContext) private var modelContext + private func partnerName(for partnerId: String) -> String { - let dmMessages = manager.messages["direct-messages"] ?? [] - return dmMessages.first { $0.senderId == partnerId }?.senderName ?? partnerId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.buffer == "direct-messages" && $0.senderId == partnerId } + ) + let results = (try? modelContext.fetch(descriptor)) ?? [] + return results.first?.senderName ?? partnerId } - private func navigateToDM(_ message: ChatMessage) { + private func navigateToDM(_ message: SavedMessage) { guard let senderId = message.senderId else { return } selectedBuffer = "direct-messages" selectedDMPartner = senderId diff --git a/KDChat/Views/DMConversationView.swift b/KDChat/Views/DMConversationView.swift index d8aad3b..4ac71cf 100644 --- a/KDChat/Views/DMConversationView.swift +++ b/KDChat/Views/DMConversationView.swift @@ -1,15 +1,24 @@ +import SwiftData import SwiftUI struct DMConversationView: View { let partnerId: String let partnerName: String @Environment(ConnectionManager.self) private var manager + @Query private var messages: [SavedMessage] @State private var messageText = "" - private var messages: [ChatMessage] { - (manager.messages["direct-messages"] ?? []).filter { message in - message.senderId == partnerId || (message.senderIsRecipient && !message.isSystem) - } + init(partnerId: String, partnerName: String) { + self.partnerId = partnerId + self.partnerName = partnerName + + let pid = partnerId + _messages = Query( + filter: #Predicate { + $0.buffer == "direct-messages" && $0.characterId == pid + }, + sort: \.timestamp + ) } var body: some View { @@ -19,16 +28,14 @@ struct DMConversationView: View { LazyVStack(alignment: .leading, spacing: 8) { ForEach(messages) { message in MessageRow(message: message) - .id(message.id) + .id(message.persistentModelID) } } .padding() } .onChange(of: messages.count) { - if let lastId = messages.last?.id { - withAnimation(.easeOut(duration: 0.2)) { - proxy.scrollTo(lastId, anchor: .bottom) - } + if let last = messages.last { + proxy.scrollTo(last.persistentModelID, anchor: .bottom) } } } diff --git a/KDChat/Views/DMListView.swift b/KDChat/Views/DMListView.swift index 70e2ff8..1b75670 100644 --- a/KDChat/Views/DMListView.swift +++ b/KDChat/Views/DMListView.swift @@ -1,21 +1,23 @@ +import SwiftData import SwiftUI struct DMListView: View { @Environment(ConnectionManager.self) private var manager @Binding var selectedConversation: String? + @Query( + filter: #Predicate { $0.buffer == "direct-messages" && !$0.isSystem }, + sort: \.timestamp, + order: .reverse + ) private var dmMessages: [SavedMessage] - private var conversations: [(id: String, name: String, lastMessage: ChatMessage)] { - let dmMessages = manager.messages["direct-messages"] ?? [] - var latest: [String: (name: String, message: ChatMessage)] = [:] - - for message in dmMessages where !message.isSystem { - // The conversation partner is the other person + private var conversations: [(id: String, name: String, lastMessage: SavedMessage)] { + var latest: [String: (name: String, message: SavedMessage)] = [:] + for message in dmMessages { guard let senderId = message.senderId, !message.senderIsRecipient else { continue } - if latest[senderId].map({ message.timestamp > $0.message.timestamp }) ?? true { + if latest[senderId] == nil { latest[senderId] = (message.senderName, message) } } - return latest .map { (id: $0.key, name: $0.value.name, lastMessage: $0.value.message) } .sorted { $0.lastMessage.timestamp > $1.lastMessage.timestamp } diff --git a/KDChat/Views/MessageRow.swift b/KDChat/Views/MessageRow.swift index 4856110..6e0f355 100644 --- a/KDChat/Views/MessageRow.swift +++ b/KDChat/Views/MessageRow.swift @@ -1,9 +1,9 @@ import SwiftUI struct MessageRow: View { - let message: ChatMessage - var onDM: ((ChatMessage) -> Void)? - var onProfile: ((ChatMessage) -> Void)? + let message: SavedMessage + var onDM: ((SavedMessage) -> Void)? + var onProfile: ((SavedMessage) -> Void)? @Environment(ConnectionManager.self) private var manager private var isOwnMessage: Bool { diff --git a/KDChat/Views/SettingsView.swift b/KDChat/Views/SettingsView.swift index 73788ac..eda8fd6 100644 --- a/KDChat/Views/SettingsView.swift +++ b/KDChat/Views/SettingsView.swift @@ -1,6 +1,28 @@ +import SwiftData import SwiftUI +enum DeleteAfter: Int, CaseIterable { + case oneDay = 1 + case oneWeek = 7 + case oneMonth = 30 + case oneYear = 365 + case never = 0 + + var label: String { + switch self { + case .oneDay: "1 Day" + case .oneWeek: "1 Week" + case .oneMonth: "1 Month" + case .oneYear: "1 Year" + case .never: "Never" + } + } +} + struct SettingsView: View { + @AppStorage("save_messages") private var saveMessages = true + @AppStorage("save_debug_buffer") private var saveDebugBuffer = false + @AppStorage("delete_after") private var deleteAfterRaw = DeleteAfter.oneMonth.rawValue @AppStorage("haptics_enabled") private var hapticsEnabled = true @AppStorage("haptics_messages") private var messageHaptics = true @AppStorage("haptics_status") private var statusHaptics = true @@ -9,9 +31,44 @@ struct SettingsView: View { @AppStorage("sounds_map") private var mapSound = true @AppStorage("sounds_dm") private var dmSound = true @AppStorage("sounds_background_buffers") private var backgroundBufferSounds = true + @Environment(\.modelContext) private var modelContext + @State private var showDeleteConfirmation = false + + private var deleteAfter: Binding { + Binding( + get: { DeleteAfter(rawValue: deleteAfterRaw) ?? .oneMonth }, + set: { deleteAfterRaw = $0.rawValue } + ) + } var body: some View { Form { + Section("Messages") { + Toggle("Save Messages", isOn: $saveMessages) + Toggle("Save Debug Buffer", isOn: $saveDebugBuffer) + .disabled(!saveMessages) + Picker("Delete After", selection: deleteAfter) { + ForEach(DeleteAfter.allCases, id: \.self) { option in + Text(option.label).tag(option) + } + } + .disabled(!saveMessages) + Button("Delete All Messages", role: .destructive) { + showDeleteConfirmation = true + } + .confirmationDialog( + "Delete Messages", + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button("Delete All Messages", role: .destructive) { + deleteAllMessages() + } + } message: { + Text("Are you sure you want to delete all messages from all buffers?") + } + } + Section("Sounds") { Toggle("Sounds", isOn: $soundsEnabled) Toggle("Global Chat", isOn: $globalSound) @@ -43,5 +100,15 @@ struct SettingsView: View { } } .navigationTitle("Settings") + .onChange(of: saveMessages) { + if !saveMessages { + deleteAllMessages() + } + } + } + + private func deleteAllMessages() { + try? modelContext.delete(model: SavedMessage.self) + try? modelContext.save() } }