KDChat/KDChat/Views/ChatView.swift
Blake Oliver 8a39e5939f
Add swiftData message persistence and reactive side effects
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
2026-04-05 18:46:38 -06:00

97 lines
3.6 KiB
Swift

import SwiftData
import SwiftUI
struct ChatView: View {
@Environment(ConnectionManager.self) private var manager
@State private var selectedBuffer: String = "global"
@State private var showSettings = false
@State private var selectedDMPartner: String?
var body: some View {
NavigationStack {
TabView(selection: $selectedBuffer) {
ForEach(manager.buffers) { buffer in
if buffer.name == "direct-messages" {
Tab(buffer.displayName, systemImage: iconForBuffer(buffer.name), value: buffer.name) {
DMListView(selectedConversation: $selectedDMPartner)
}
} else {
Tab(buffer.displayName, systemImage: iconForBuffer(buffer.name), value: buffer.name) {
ChatBufferView(bufferName: buffer.name, canSend: canSend(buffer)) { message in
navigateToDM(message)
}
}
}
}
}
.navigationTitle(titleForBuffer(selectedBuffer))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu("More", systemImage: "ellipsis.circle") {
Button("Switch Character", systemImage: "person.2") {
manager.switchCharacter()
}
Button("Settings", systemImage: "gearshape") {
showSettings = true
}
Button("Log Out", systemImage: "rectangle.portrait.and.arrow.right", role: .destructive) {
manager.logout()
}
}
}
}
.navigationDestination(isPresented: $showSettings) {
SettingsView()
}
.navigationDestination(for: String.self) { partnerId in
let name = partnerName(for: partnerId)
DMConversationView(partnerId: partnerId, partnerName: name)
}
.onChange(of: selectedBuffer) {
manager.activeBuffer = selectedBuffer
}
}
}
private func canSend(_ buffer: ChatBuffer) -> Bool {
if buffer.name == "direct-messages" { return true }
return buffer.canSend
}
private func titleForBuffer(_ name: String) -> String {
switch name {
case "global": "Kirandur Global Chat"
case "map": "Kirandur Map Chat"
case "debug": "Kirandur Debug Output"
case "direct-messages": "Kirandur DMs"
default: "Kirandur \(name.capitalized)"
}
}
private func iconForBuffer(_ name: String) -> String {
switch name {
case "global": "globe"
case "map": "map"
case "debug": "ladybug"
case "direct-messages": "envelope"
default: "bubble.left"
}
}
@Environment(\.modelContext) private var modelContext
private func partnerName(for partnerId: String) -> String {
let descriptor = FetchDescriptor<SavedMessage>(
predicate: #Predicate { $0.buffer == "direct-messages" && $0.senderId == partnerId }
)
let results = (try? modelContext.fetch(descriptor)) ?? []
return results.first?.senderName ?? partnerId
}
private func navigateToDM(_ message: SavedMessage) {
guard let senderId = message.senderId else { return }
selectedBuffer = "direct-messages"
selectedDMPartner = senderId
}
}